Files
MobilityOps/frontend/src/context/AuthContext.tsx
T
NuklearRabbit 81e3fd63bd
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped
M54: harden operations and demo resilience
2026-08-24 03:31:03 +02:00

158 lines
5.3 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, ApiError, onUnauthorized } from "../api/client";
import type { CurrentUser, Role, SystemStatus } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
demoMode: boolean;
oidcEnabled: boolean;
oidcProviderName: string | null;
loginAs: (role: Role) => Promise<void>;
loginWithPassword: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user";
function readCachedUser(): CurrentUser | null {
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
} catch {
// A corrupted or blocked sessionStorage must never prevent the app from booting;
// the session endpoint remains the source of truth.
return null;
}
}
function cacheUser(user: CurrentUser | null) {
try {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
} catch {
// Session storage is only a paint optimisation. Browser privacy settings or a full
// storage quota must never turn a successful server-side login/logout into a client
// failure; the HttpOnly session cookie remains authoritative.
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
// The cached value only avoids a login-screen flash while the server round trip below is
// in flight; it is never trusted on its own. `loading` stays true until the server has
// confirmed (or rejected) the session cookie.
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
const [loading, setLoading] = useState(true);
const [demoMode, setDemoMode] = useState(true);
const [oidcEnabled, setOidcEnabled] = useState(false);
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
// System capabilities and session identity are independent reads. In particular, a
// slow public status endpoint must not hold an already valid session (or the login
// controls) behind its timeout.
void api.get<SystemStatus>("/api/v1/system/status", { signal: controller.signal })
.then((system) => {
if (controller.signal.aborted) return;
setDemoMode(system.demo_mode);
setOidcEnabled(system.oidc_enabled);
setOidcProviderName(system.oidc_provider_name);
})
.catch(() => {
if (!controller.signal.aborted) setDemoMode(true);
});
void api
.get<CurrentUser>("/api/v1/auth/session", { signal: controller.signal })
.then((confirmed) => {
if (controller.signal.aborted) return;
setUser(confirmed);
cacheUser(confirmed);
})
.catch(() => {
if (controller.signal.aborted) return;
setUser(null);
cacheUser(null);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);
useEffect(
() =>
onUnauthorized(() => {
setUser(null);
cacheUser(null);
}),
[],
);
const loginAs = useCallback(async (role: Role) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
setUser(loggedIn);
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const loginWithPassword = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/auth/login", { email, password });
setUser(loggedIn);
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
try {
// A logout response expires an HttpOnly cookie. Chromium can expose a very small
// race between resolving fetch() and applying that Set-Cookie header to an
// immediate top-level navigation. Confirm the server now rejects the session
// before allowing the router to continue; retrying logout is idempotent.
for (let attempt = 0; attempt < 3; attempt += 1) {
await api.post("/api/v1/auth/logout");
try {
await api.get<CurrentUser>("/api/v1/auth/session");
} catch (err) {
if (err instanceof ApiError && err.status === 401) return;
throw err;
}
}
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// still drops its own state when the request itself is unavailable.
} finally {
// Keep RequireAuth from redirecting to /login while the server-side invalidation
// is still in flight. Otherwise a very fast navigation can race ahead of logout.
setUser(null);
cacheUser(null);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, demoMode, oidcEnabled, oidcProviderName, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}