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("loading"); const [me, setMeState] = useState(null); const [departments, setDepartments] = useState([]); const [notice, setNotice] = useState(null); const statusRef = useRef("loading"); const lastActivity = useRef(Date.now()); const deptsFor = useRef(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(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( () => ({ 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 {children}; }