feat(auth): add server-backed demo sessions

The browser treated sessionStorage as the source of truth for the logged-in
user and never verified or invalidated the server-side session cookie: no
GET /api/v1/demo/session or POST /api/v1/demo/logout endpoint existed, and a
central 401 handler was defined but never wired up.

Add both endpoints; the session-check response is marked Cache-Control:
no-store to avoid the browser serving a stale "authenticated" response right
after logout. AuthProvider now verifies against the server on every mount
(sessionStorage only caches presentation state to avoid a login-screen
flash), subscribes to a central 401 listener on the API client, and
RequireAuth shows a loading state during verification instead of flashing
protected content or the wrong role.
This commit is contained in:
NuklearRabbit
2026-08-02 04:51:54 +02:00
parent 56a65b2364
commit ffc88e33b4
7 changed files with 149 additions and 22 deletions
+11
View File
@@ -1,5 +1,13 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void;
const unauthorizedListeners = new Set<UnauthorizedListener>();
export function onUnauthorized(listener: UnauthorizedListener): () => void {
unauthorizedListeners.add(listener);
return () => unauthorizedListeners.delete(listener);
}
export class ApiError extends Error {
status: number;
code: string;
@@ -31,6 +39,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
body = undefined;
}
const error = body?.error;
if (response.status === 401) {
unauthorizedListeners.forEach((listener) => listener());
}
throw new ApiError(
response.status,
error?.code ?? String(response.status),
+9 -1
View File
@@ -1,9 +1,17 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { LoadingState } from "./PageChrome";
export function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useAuth();
const { user, loading } = useAuth();
if (loading) {
return (
<div className="page">
<LoadingState label="Verifying session…" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
+62 -18
View File
@@ -1,45 +1,93 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { api, ApiError } from "../api/client";
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, onUnauthorized } from "../api/client";
import type { CurrentUser, Role } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
loginAs: (role: Role) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user";
function readCachedUser(): CurrentUser | null {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
}
function cacheUser(user: CurrentUser | null) {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(() => {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
});
const [loading, setLoading] = useState(false);
// 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);
useEffect(() => {
let cancelled = false;
api
.get<CurrentUser>("/api/v1/demo/session")
.then((confirmed) => {
if (cancelled) return;
setUser(confirmed);
cacheUser(confirmed);
})
.catch(() => {
if (cancelled) return;
setUser(null);
cacheUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
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);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn));
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
const logout = useCallback(async () => {
setUser(null);
sessionStorage.removeItem(STORAGE_KEY);
cacheUser(null);
try {
await api.post("/api/v1/demo/logout");
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// already dropped its own state either way.
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>
{children}
</AuthContext.Provider>
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>{children}</AuthContext.Provider>
);
}
@@ -48,7 +96,3 @@ export function useAuth(): AuthState {
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export function isSessionExpired(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}