Files
gerbeur/src/hooks/auth/useAuth.ts

245 lines
7.1 KiB
TypeScript

/**
* Gives components the session, and the functions that sign in, sign out and
* make requests with its token.
*
* @module
*/
import { useCallback, useContext } from "react";
import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
import type { UserSettings } from "../../../shared/settings.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { takeEarly } from "../../utils/shared/earlyFetch.ts";
import {
forgetStoredSession,
isTokenExpired,
storeSession,
} from "../../utils/auth/storage.ts";
/**
* Returned by {@link useAuth} to any component that needs the session. Its
* functions are stable until the session's token changes.
*/
export interface UseAuthResult {
user: SelfUser | null;
/**
* The user's settings, once `AuthProvider` has read them. `useSettings` is
* the way to read and change them.
*/
settings: UserSettings | null;
token: string | null;
/**
* The token for file and thumbnail URLs, where it shows in the query string.
* A session from before media tokens falls back to the session token until
* AuthProvider fetches one.
*/
mediaToken: string | null;
/** Starts a session from a sign-in or sign-up response, in every tab. */
login: (response: AuthResponse) => void;
/**
* Ends the session this render holds. A 401 can land after the session has
* moved on to fresh tokens (a password change, here or in another tab), and
* must not sign those out.
*/
logout: () => void;
/**
* Merges `patch` into the session, in memory and in storage. The profile page
* uses it for a new avatar or email, the password dialog for fresh tokens.
*/
updateSession: (patch: Partial<Session>) => void;
/**
* Calls `fetch` with the session's token, and a JSON content type unless the
* body is FormData. An expired token, or a 401, ends the session. An expired
* token answers a 401 without calling the server.
*/
authFetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/**
* Works like `authFetch` for a multipart upload that shows progress, as the
* dump creation dialog's file upload does. It goes through XHR, since `fetch`
* reports nothing until the whole body is sent. It still answers a
* `Response`, so callers use `expectOk` as usual.
*
* `onProgress` receives a fraction from 0 to 1, or `null` once the body is
* sent and the server is working, or when the length is unknown.
*/
authUpload: (
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method?: string,
) => Promise<Response>;
}
/**
* Returns the session and the functions that act on it. It must be called
* under `AuthProvider`.
*/
export function useAuth(): UseAuthResult {
const { authResponse, setAuthResponse } = useContext(AuthContext);
const login = useCallback((response: AuthResponse) => {
setAuthResponse(response);
storeSession(response);
}, [setAuthResponse]);
const sessionToken = authResponse?.token;
const logout = useCallback(() => {
setAuthResponse((prev) => prev?.token === sessionToken ? null : prev);
forgetStoredSession(sessionToken);
}, [setAuthResponse, sessionToken]);
const updateSession = useCallback((patch: Partial<Session>) => {
setAuthResponse((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
storeSession(next);
return next;
});
}, [setAuthResponse]);
const authFetch = useCallback(async (
input: RequestInfo,
init: RequestInit = {},
) => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
// Callers already handle a 401 from the server; this looks the same.
return new Response(null, { status: 401 });
}
const isFormData = init.body instanceof FormData;
// The page a visit lands on finds its first request already sent, as the
// app booted (utils/shared/earlyFetch.ts).
const early = typeof input === "string" &&
(init.method ?? "GET") === "GET"
? takeEarly(input, token)
: undefined;
const res = await (early ?? fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
// The browser sets FormData's Content-Type, with its boundary.
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
}));
if (res.status === 401) {
logout();
}
return res;
}, [authResponse?.token, logout]);
const authUpload = useCallback((
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method = "POST",
): Promise<Response> => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
return Promise.resolve(new Response(null, { status: 401 }));
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
// The browser sets the Content-Type, with the multipart boundary.
xhr.upload.addEventListener("progress", (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : null);
});
xhr.upload.addEventListener("load", () => onProgress(null));
xhr.addEventListener("load", () => {
if (xhr.status === 401) logout();
resolve(
new Response(xhr.responseText, {
status: xhr.status,
statusText: xhr.statusText,
}),
);
});
// The error `fetch` throws on a network failure, so that
// `friendlyFetchError` reports it the same way.
xhr.addEventListener(
"error",
() => reject(new TypeError("Failed to fetch")),
);
xhr.addEventListener(
"abort",
() => reject(new TypeError("Failed to fetch")),
);
xhr.send(body);
});
}, [authResponse?.token, logout]);
return {
user: authResponse?.user ?? null,
settings: authResponse?.settings ?? null,
token: authResponse?.token ?? null,
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
login,
logout,
updateSession,
authFetch,
authUpload,
};
}
/**
* Returned by {@link useRequiredAuth}. It is {@link UseAuthResult} with a user
* that can't be null.
*/
export interface UseRequiredAuthResult extends UseAuthResult {
user: SelfUser;
}
/**
* Returns the session, for a page that `RestrictedLoggedIn` guards, such as
* `DumpEdit`, so it needn't check for a user.
*
* @throws {Error} When no one is signed in, which means the route has no guard.
*/
export function useRequiredAuth(): UseRequiredAuthResult {
const {
user,
settings,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
} = useAuth();
if (!user) {
throw new Error(
"Invariant: useRequiredAuth called outside a protected route",
);
}
return {
user,
settings,
token,
mediaToken,
login,
logout,
updateSession,
authFetch,
authUpload,
};
}