import { createContext, useContext, createSignal, createMemo, onMount, JSXElement } from "solid-js"; import html from "solid-js/html"; import { getToken, setToken, clearToken, isTokenExpired } from "./useAuthFetch.js"; import { apiFetch } from "./checkBundleVersion.js"; export interface Identity { id: string; username: string; email: string; first_name: string; last_name: string; permissions: string[]; org_id: string | null; org_name: string | null; org_cert_num: string | null; } export interface MemberOrg { id: string; name: string; certnum: string | null; } export interface SuperAdminState { enabled: boolean; orgName: string | null; firstMemberOrgId: string | null; } export interface LoginResult { success: boolean; error?: string; } export interface SwitchOrgResult { success: boolean; org_id?: string; org_name?: string; org_cert_num?: string | null; error?: string; } export interface AuthContextValue { identity: () => Identity | null; loading: () => boolean; error: () => string | null; isAuthenticated: () => boolean; superAdminMode: () => SuperAdminState; memberOrganizations: () => MemberOrg[]; login: (username: string, password: string, rememberMe?: boolean) => Promise; logout: () => Promise; checkAuth: () => Promise; switchOrganization: (orgId: string) => Promise; disableSuperAdminMode: () => Promise; } interface AuthProviderProps { children?: JSXElement; } const AuthContext = createContext(); export function AuthProvider(props: AuthProviderProps) { const [identity, setIdentity] = createSignal(null); const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); const [memberOrganizations, setMemberOrganizations] = createSignal([]); const [isAdmin, setIsAdmin] = createSignal(false); const isAuthenticated = createMemo(() => !!identity()); const checkAuth = async (): Promise => { try { setLoading(true); setError(null); const token = getToken(); if (!token) { setIdentity(null); setLoading(false); return; } if (isTokenExpired()) { clearToken(); setIdentity(null); setLoading(false); return; } // Retry transient network failures a few times so a brief blip // self-heals instead of dropping the user to an error screen. A // 401 is a real response and is handled below, not retried. -mta 6/3/26 let response; for (let attempt = 0; ; attempt++) { try { response = await apiFetch("/api/auth/me", { headers: { "Authorization": `Bearer ${token}`, }, }); break; } catch (err) { if (attempt >= 2) throw err; await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))); } } // Only a definitive 401 means the session is actually invalid. A // network error, an aborted request (e.g. the user refreshed // mid-flight), or a 5xx must NOT clear the token -- otherwise a // quick refresh aborts this request and silently logs the user // out. -mta 6/3/26 if (response.status === 401) { clearToken(); setIdentity(null); setMemberOrganizations([]); setIsAdmin(false); return; } if (!response.ok) { throw new Error(`Auth check failed: ${response.status}`); } const data = await response.json(); setIdentity(data); const orgsResponse = await apiFetch("/api/auth/my-organizations", { headers: { "Authorization": `Bearer ${token}`, }, }); if (orgsResponse.ok) { const orgsData = await orgsResponse.json(); setMemberOrganizations(orgsData.member_organizations || []); setIsAdmin(orgsData.is_admin || false); } } catch (err) { // Transient failure (network/abort/5xx). Preserve the token so the // next load can retry instead of forcing a logout. -mta 6/3/26 setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); } }; onMount(() => { checkAuth(); }); const login = async (username: string, password: string, rememberMe = false): Promise => { try { setLoading(true); setError(null); const response = await apiFetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ username, password, remember_me: rememberMe, }), }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || "Login failed"); } setToken(data.token, data.expires_at); setIdentity(data.user); const orgsResponse = await apiFetch("/api/auth/my-organizations", { headers: { "Authorization": `Bearer ${data.token}`, }, }); if (orgsResponse.ok) { const orgsData = await orgsResponse.json(); setMemberOrganizations(orgsData.member_organizations || []); setIsAdmin(orgsData.is_admin || false); } return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; setError(message); return { success: false, error: message }; } finally { setLoading(false); } }; const logout = async (): Promise => { const token = getToken(); if (token) { try { await apiFetch("/api/auth/logout", { method: "POST", headers: { "Authorization": `Bearer ${token}`, }, }); } catch { // Continue with logout even if API call fails } } clearToken(); setIdentity(null); window.location.href = "/login"; }; const switchOrganization = async (orgId: string): Promise => { try { setError(null); const token = getToken(); if (!token) { throw new Error("Not authenticated"); } const response = await apiFetch("/api/auth/switch-org", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, body: JSON.stringify({ org_id: orgId }), }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || "Failed to switch organization"); } setIdentity((prev) => prev ? { ...prev, org_id: data.org_id, org_name: data.org_name, org_cert_num: data.org_cert_num, } : null); return { success: true, org_id: data.org_id, org_name: data.org_name, org_cert_num: data.org_cert_num, }; } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; setError(message); return { success: false, error: message }; } }; const disableSuperAdminMode = async (): Promise => { const orgs = memberOrganizations(); if (orgs.length > 0) { await switchOrganization(orgs[0].id); } }; const superAdminMode = (): SuperAdminState => { const id = identity(); const orgs = memberOrganizations(); return { enabled: isAdmin() && id?.org_id != null && !orgs.some((org) => org.id === id.org_id), orgName: id?.org_name || null, firstMemberOrgId: orgs.length > 0 ? orgs[0].id : null, }; }; const value: AuthContextValue = { identity, loading, error, isAuthenticated, superAdminMode, memberOrganizations, login, logout, checkAuth, switchOrganization, disableSuperAdminMode, }; // CRITICAL: Use lazy children evaluation return html`<${AuthContext.Provider} value=${value}>${() => props.children}`; } export function useAuth(): AuthContextValue { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used within an AuthProvider"); } return context; }