Files
PacketStation/frontend/src/auth/AuthProvider.tsx
T

232 lines
7.5 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { ApiError, errorCode, onApiError } from "../api/client";
import { authApi, departmentsApi } from "./api";
import { AuthCtx, type AuthState, type AuthStatus } from "./context";
import { roleLabel as labelFor } from "./roles";
import type { Department, Factor, Me } from "./types";
import { useEvents } from "./useEvents";
/** The old build's screen lock. The server locks at SESSION_LOCK_MINUTES too; it is the authority. */
export const IDLE_LOCK_MS = 3 * 60_000;
/** A tab hidden for longer than this comes back locked (the old build's rule). */
export const HIDDEN_LOCK_MS = 60_000;
const IDLE_CHECK_MS = 15_000;
const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart"] as const;
/** 401 codes that mean this browser's session is gone. Other 401s (a wrong unlock PIN) are not. */
const SESSION_GONE = new Set(["no_session", "session_expired", "session_ended"]);
function goneNotice(err: ApiError): string {
const code = errorCode(err);
if (code === "no_session") return "Your session ended. Sign in again.";
return err.message || "Your session ended. Sign in again.";
}
interface AuthProviderProps {
children: ReactNode;
/** Idle time before the local lock. Tests pass a small value. */
idleLockMs?: number;
/** How often the idle timer checks. */
idleCheckMs?: number;
/** Hidden time after which the tab comes back locked. Tests pass a small value. */
hiddenLockMs?: number;
}
export function AuthProvider({ children, idleLockMs = IDLE_LOCK_MS, idleCheckMs = IDLE_CHECK_MS, hiddenLockMs = HIDDEN_LOCK_MS }: AuthProviderProps) {
const [status, setStatusState] = useState<AuthStatus>("loading");
const [me, setMeState] = useState<Me | null>(null);
const [departments, setDepartments] = useState<Department[]>([]);
const [notice, setNotice] = useState<string | null>(null);
const statusRef = useRef<AuthStatus>("loading");
const lastActivity = useRef(Date.now());
const deptsFor = useRef<string | null>(null);
const setStatus = useCallback((s: AuthStatus) => {
statusRef.current = s;
setStatusState(s);
}, []);
const accept = useCallback(
(m: Me) => {
setMeState(m);
setNotice(null);
lastActivity.current = Date.now();
setStatus(m.session.locked ? "locked" : "ready");
},
[setStatus],
);
const endSession = useCallback(
(msg: string | null) => {
setMeState(null);
setDepartments([]);
deptsFor.current = null;
setNotice(msg);
setStatus("anonymous");
},
[setStatus],
);
const refresh = useCallback(async () => {
try {
accept(await authApi.me());
} catch (err) {
if (err instanceof ApiError && err.status === 423) setStatus("locked");
else if (err instanceof ApiError && err.status === 401) endSession(null);
else endSession(err instanceof Error ? err.message : "Could not reach the server.");
}
}, [accept, endSession, setStatus]);
useEffect(() => {
void refresh();
}, [refresh]);
// 401 and 423 from any call, not only the auth calls.
useEffect(
() =>
onApiError((err, path) => {
if (path.startsWith("/api/auth/signin")) return;
const st = statusRef.current;
if (st === "loading" || st === "anonymous") return;
if (err.status === 423) {
setStatus("locked");
} else if (err.status === 401 && SESSION_GONE.has(errorCode(err) ?? "")) {
endSession(goneNotice(err));
}
}),
[endSession, setStatus],
);
// Department names for the role labels, once per signed-in user.
const userId = me?.user.id ?? null;
useEffect(() => {
if (status !== "ready" || !userId || deptsFor.current === userId) return;
deptsFor.current = userId;
departmentsApi
.list()
.then((d) => setDepartments(Array.isArray(d) ? d : []))
.catch(() => {
deptsFor.current = null;
});
}, [status, userId]);
const signInPassword = useCallback(
async (login: string, password: string) => {
const m = await authApi.signInPassword(login, password);
accept(m);
return m;
},
[accept],
);
const signInPin = useCallback(
async (empNo: string, pin: string) => {
const m = await authApi.signInPin(empNo, pin);
accept(m);
return m;
},
[accept],
);
const signOut = useCallback(async () => {
try {
await authApi.signOut();
} catch {
// The cookie may already be gone. Signing out locally is what matters here.
}
endSession(null);
}, [endSession]);
const unlock = useCallback(
async (factor: Factor, secret: string) => {
const m = await authApi.unlock(factor, secret);
accept({ ...m, session: { ...m.session, locked: false } });
return m;
},
[accept],
);
const lock = useCallback(async () => {
if (statusRef.current !== "ready") return;
setStatus("locked");
try {
await authApi.lock();
} catch {
// A 401 here signs out through the listener. Otherwise the local lock stands.
}
}, [setStatus]);
// Hidden is reported, never locked at once: a reload or navigation fires hidden on the old page.
// Coming back is reported, then Me decides. Hidden longer than hiddenLockMs locks locally right away.
const hiddenAt = useRef<number | null>(null);
useEffect(() => {
const onVisibility = () => {
const st = statusRef.current;
if (st !== "ready" && st !== "locked") return;
if (document.hidden) {
hiddenAt.current = Date.now();
authApi.visibility(true).catch(() => undefined);
return;
}
const since = hiddenAt.current;
hiddenAt.current = null;
if (since !== null && Date.now() - since > hiddenLockMs && st === "ready") setStatus("locked");
authApi
.visibility(false)
.catch(() => undefined)
.then(() => refresh());
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [setStatus, refresh, hiddenLockMs]);
// Local idle lock.
useEffect(() => {
const bump = () => {
lastActivity.current = Date.now();
};
for (const ev of ACTIVITY_EVENTS) document.addEventListener(ev, bump, { capture: true, passive: true });
return () => {
for (const ev of ACTIVITY_EVENTS) document.removeEventListener(ev, bump, { capture: true });
};
}, []);
useEffect(() => {
if (status !== "ready") return;
lastActivity.current = Date.now();
const id = window.setInterval(() => {
if (Date.now() - lastActivity.current >= idleLockMs) void lock();
}, idleCheckMs);
return () => window.clearInterval(id);
}, [status, idleLockMs, idleCheckMs, lock]);
const sessionId = status === "ready" || status === "locked" ? (me?.session.id ?? null) : null;
const presence = useEvents(sessionId, {
onRevoked: () => endSession("This session was signed out from another place."),
});
const roleLabel = useCallback((role: string) => labelFor(role, departments), [departments]);
const value = useMemo<AuthState>(
() => ({
status,
me,
departments,
notice,
presence,
refresh,
signInPassword,
signInPin,
signOut,
endSession,
unlock,
lock,
setMe: setMeState,
roleLabel,
}),
[status, me, departments, notice, presence, refresh, signInPassword, signInPin, signOut, endSession, unlock, lock, roleLabel],
);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}