/** * 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) => 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; /** * 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; } /** * 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) => { 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 => { 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, }; }