185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
|
|
|
import { type User } from "./auth-context";
|
|
|
|
// A 5xx or network error means the server is unreachable, not that the session ended.
|
|
const HTTP_SERVER_ERROR = 500;
|
|
|
|
type Authentication
|
|
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
|
|
|
type Credentials
|
|
= paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
|
|
|
|
async function request(
|
|
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
|
email: string,
|
|
password: string,
|
|
): Promise<Authentication> {
|
|
const { data, error } = await apiClient.POST(path, {
|
|
body: { email, password },
|
|
} satisfies { body: Credentials });
|
|
|
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
|
if (error) throw error;
|
|
|
|
return data;
|
|
}
|
|
|
|
function useAuthProviderVm() {
|
|
const queryClient = useQueryClient();
|
|
const [user, setUser] = useState<undefined | User>(undefined);
|
|
const accessTokenRef = useRef<string | undefined>(undefined);
|
|
const [ready, setReady] = useState(false);
|
|
const [signedOut, setSignedOut] = useState(false);
|
|
const [unavailable, setUnavailable] = useState(false);
|
|
const unavailableRef = useRef(false);
|
|
|
|
const generationRef = useRef(0);
|
|
const userRef = useRef<undefined | User>(undefined);
|
|
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
|
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
|
|
|
|
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
|
|
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
|
|
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
|
const next = authQueueRef.current.then(run);
|
|
|
|
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
|
authQueueRef.current = next.catch(() => {});
|
|
|
|
return next;
|
|
}, []);
|
|
|
|
const markUnavailable = useCallback((isUnavailable: boolean) => {
|
|
unavailableRef.current = isUnavailable;
|
|
setUnavailable(isUnavailable);
|
|
}, []);
|
|
|
|
const applyAuthentication = useCallback(
|
|
(authentication: Authentication) => {
|
|
const changedIdentity
|
|
= userRef.current && userRef.current.id !== authentication.user.id;
|
|
|
|
setSignedOut(false);
|
|
markUnavailable(false);
|
|
accessTokenRef.current = authentication.accessToken;
|
|
|
|
if (changedIdentity) {
|
|
generationRef.current += 1;
|
|
queryClient.removeQueries({ type: "inactive" });
|
|
queryClient.getMutationCache().clear();
|
|
void queryClient.resetQueries();
|
|
}
|
|
|
|
userRef.current = authentication.user;
|
|
setUser(authentication.user);
|
|
},
|
|
[markUnavailable, queryClient],
|
|
);
|
|
|
|
const clearSession = useCallback(() => {
|
|
markUnavailable(false);
|
|
generationRef.current += 1;
|
|
userRef.current = undefined;
|
|
setUser(undefined);
|
|
accessTokenRef.current = undefined;
|
|
queryClient.clear();
|
|
}, [markUnavailable, queryClient]);
|
|
|
|
const refresh = useCallback((): Promise<boolean> => {
|
|
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
|
const started = generationRef.current;
|
|
|
|
const pending = serialize(async () => {
|
|
if (started !== generationRef.current) return false;
|
|
|
|
try {
|
|
const { data, response } = await apiClient.POST("/api/v1/auth/refresh");
|
|
if (started !== generationRef.current) return false;
|
|
|
|
if (!data) {
|
|
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
|
|
else clearSession();
|
|
|
|
return false;
|
|
}
|
|
|
|
applyAuthentication(data);
|
|
|
|
return true;
|
|
}
|
|
catch {
|
|
if (started === generationRef.current) markUnavailable(true);
|
|
|
|
return false;
|
|
}
|
|
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
|
}).finally(() => {
|
|
refreshPromiseRef.current = undefined;
|
|
});
|
|
|
|
refreshPromiseRef.current = pending;
|
|
|
|
return pending;
|
|
}, [applyAuthentication, clearSession, markUnavailable, serialize]);
|
|
|
|
useEffect(() => {
|
|
void refresh().finally(() => setReady(true));
|
|
}, [refresh]);
|
|
|
|
async function authenticate(
|
|
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
|
email: string,
|
|
password: string,
|
|
) {
|
|
clearSession();
|
|
const started = generationRef.current;
|
|
await serialize(async () => {
|
|
const authentication = await request(path, email, password);
|
|
if (started === generationRef.current)
|
|
applyAuthentication(authentication);
|
|
});
|
|
}
|
|
|
|
async function logout() {
|
|
setSignedOut(true);
|
|
clearSession();
|
|
await serialize(async () => {
|
|
await apiClient.POST("/api/v1/auth/logout");
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
configureAuthClient({
|
|
accessToken: () => accessTokenRef.current,
|
|
clear: clearSession,
|
|
generation: () => generationRef.current,
|
|
refresh,
|
|
unavailable: () => unavailableRef.current,
|
|
});
|
|
// `user` tracks access-token changes, which configureAuthClient publishes as session state.
|
|
}, [clearSession, refresh, user]);
|
|
useEffect(() => () => configureAuthClient(), []);
|
|
|
|
const authContextValue = {
|
|
login: (email: string, password: string) =>
|
|
authenticate("/api/v1/auth/login", email, password),
|
|
logout,
|
|
ready,
|
|
register: (email: string, password: string) =>
|
|
authenticate("/api/v1/auth/register", email, password),
|
|
retry: () => void refresh(),
|
|
signedOut,
|
|
unavailable,
|
|
user,
|
|
};
|
|
|
|
return authContextValue;
|
|
}
|
|
|
|
export { useAuthProviderVm };
|