232 lines
7.5 KiB
TypeScript
232 lines
7.5 KiB
TypeScript
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>;
|
||
|
|
}
|