import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { configureHttpAuth } from "../api/http"; import type { AuthState, MeResponse } from "../api/types"; import { changePassword as changePasswordApi } from "../api/auth"; import { clearPwaRuntimeCaches } from "../pwa"; import { fetchCurrentEmployee, loginWithPassword, logoutSession, refreshSession, } from "./authService"; import { resolvePermissionsForRoles } from "./permissions"; type OfflineAuthSnapshot = { version: 1; me: MeResponse; permissions: string[]; savedAt: string; }; type AuthContextValue = AuthState & { login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>; changePassword: (oldPassword: string, newPassword: string) => Promise; logout: () => Promise; bootstrapByRefresh: () => Promise; clearSession: () => void; offlineAccessMode: boolean; }; const AuthContext = createContext(null); const EMPTY_STATE: AuthState = { accessToken: null, me: null, permissions: [], mustChangePassword: false, ready: false, }; const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password"; const OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY = "hyb-oa.auth_snapshot"; const OFFLINE_AUTH_SNAPSHOT_VERSION = 1; const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; function isWindowAvailable() { return typeof window !== "undefined"; } function readMustChangePasswordFromStorage() { return isWindowAvailable() && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1"; } function writeMustChangePasswordToStorage(value: boolean) { if (!isWindowAvailable()) { return; } if (value) { window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1"); } else { window.localStorage.removeItem(MUST_CHANGE_PASSWORD_STORAGE_KEY); } } function readOfflineAuthSnapshot() { if (!isWindowAvailable()) { return null; } try { const rawValue = window.localStorage.getItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); if (!rawValue) { return null; } const parsed = JSON.parse(rawValue) as Partial | null; if (!parsed || parsed.version !== OFFLINE_AUTH_SNAPSHOT_VERSION || !parsed.me || !Array.isArray(parsed.permissions) || typeof parsed.savedAt !== "string") { window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); return null; } const savedAt = new Date(parsed.savedAt); if (Number.isNaN(savedAt.getTime()) || Date.now() - savedAt.getTime() > OFFLINE_AUTH_SNAPSHOT_TTL_MS) { window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); return null; } return { version: OFFLINE_AUTH_SNAPSHOT_VERSION, me: parsed.me, permissions: parsed.permissions.map((item) => String(item)), savedAt: savedAt.toISOString(), } satisfies OfflineAuthSnapshot; } catch { window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); return null; } } function writeOfflineAuthSnapshot(me: MeResponse, permissions: string[]) { if (!isWindowAvailable()) { return; } const snapshot: OfflineAuthSnapshot = { version: OFFLINE_AUTH_SNAPSHOT_VERSION, me, permissions, savedAt: new Date().toISOString(), }; window.localStorage.setItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot)); } function clearOfflineAuthSnapshot() { if (!isWindowAvailable()) { return; } window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY); } function buildStateWithOfflineSnapshot(snapshot: OfflineAuthSnapshot): AuthState { return { accessToken: null, me: snapshot.me, permissions: snapshot.permissions, mustChangePassword: false, ready: true, }; } export function AuthProvider({ children }: PropsWithChildren) { const [state, setState] = useState(EMPTY_STATE); const [offlineAccessMode, setOfflineAccessMode] = useState(false); const tokenRef = useRef(null); useEffect(() => { tokenRef.current = state.accessToken; }, [state.accessToken]); const clearSession = useCallback(() => { tokenRef.current = null; setOfflineAccessMode(false); writeMustChangePasswordToStorage(false); clearOfflineAuthSnapshot(); void clearPwaRuntimeCaches(); setState((previous) => ({ ...previous, accessToken: null, me: null, permissions: [], mustChangePassword: false, ready: true, })); }, []); const commitAuthenticatedUser = useCallback( (accessToken: string, me: MeResponse, mustChangePassword: boolean) => { const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); tokenRef.current = accessToken; setOfflineAccessMode(false); writeMustChangePasswordToStorage(mustChangePassword); writeOfflineAuthSnapshot(me, permissions); setState({ accessToken, me, permissions, mustChangePassword, ready: true, }); }, [], ); const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => { tokenRef.current = null; setOfflineAccessMode(true); setState(buildStateWithOfflineSnapshot(snapshot)); }, []); const bootstrapByRefresh = useCallback(async () => { const offlineSnapshot = readOfflineAuthSnapshot(); if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { activateOfflineSnapshot(offlineSnapshot); return; } try { const refreshed = await refreshSession(); const me = await fetchCurrentEmployee(refreshed.accessToken); const mustChangePassword = readMustChangePasswordFromStorage(); commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); } catch { if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { activateOfflineSnapshot(offlineSnapshot); return; } clearSession(); } }, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]); const login = useCallback( async (identifier: string, password: string) => { const result = await loginWithPassword({ identifier, password }); const mustChangePassword = Boolean(result.mustChangePassword); const me = await fetchCurrentEmployee(result.accessToken); commitAuthenticatedUser(result.accessToken, me, mustChangePassword); return { mustChangePassword }; }, [commitAuthenticatedUser], ); const changePassword = useCallback( async (oldPassword: string, newPassword: string) => { const result = await changePasswordApi({ oldPassword, newPassword }); const me = await fetchCurrentEmployee(result.accessToken); commitAuthenticatedUser(result.accessToken, me, false); }, [commitAuthenticatedUser], ); const logout = useCallback(async () => { try { await logoutSession(); } finally { clearSession(); } }, [clearSession]); useEffect(() => { configureHttpAuth({ getAccessToken: () => tokenRef.current, refreshAccessToken: async () => { try { const refreshed = await refreshSession(); tokenRef.current = refreshed.accessToken; setOfflineAccessMode(false); setState((previous) => ({ ...previous, accessToken: refreshed.accessToken, ready: true, })); return refreshed.accessToken; } catch { clearSession(); return null; } }, clearSession, }); }, [clearSession]); useEffect(() => { void bootstrapByRefresh(); }, [bootstrapByRefresh]); useEffect(() => { if (!offlineAccessMode) { return undefined; } const handleOnline = () => { void bootstrapByRefresh(); }; window.addEventListener("online", handleOnline); return () => { window.removeEventListener("online", handleOnline); }; }, [bootstrapByRefresh, offlineAccessMode]); const value = useMemo( () => ({ ...state, login, changePassword, logout, bootstrapByRefresh, clearSession, offlineAccessMode, }), [bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, state], ); return {children}; } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within AuthProvider"); } return context; }