245 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|