TASK-004: Bootstrap frontend auth state from backend session check

Validates session on app mount by calling GET /api/auth/session instead of relying
solely on cached sessionStorage. This ensures the UI state always reflects server
reality — expired or revoked sessions are detected immediately.

Changes:
- Backend: Add GET /api/auth/session endpoint (requires valid session, returns 200/401)
- Frontend: Add useSessionValidation hook for mount-time validation
- Frontend: Add SessionValidationLoading component for validation spinner
- Frontend: Update AuthProvider to call validation on mount with loading state
- Frontend: Add validateSession API function
- Docs: Update Features.md with session validation behavior
- Docs: Update Web-Development.md with session validation pattern

Handles three outcomes:
1. Valid session (200): Proceed with cached state
2. Invalid session (401): Clear sessionStorage and redirect to login
3. Network error: Don't logout (backend may be temporarily unreachable)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 12:00:21 +02:00
parent d982fe3efc
commit 29daaa9906
11 changed files with 1314 additions and 15 deletions

View File

@@ -1,7 +1,7 @@
/**
* Authentication API functions.
*
* Wraps calls to POST /api/auth/login and POST /api/auth/logout
* Wraps calls to POST /api/auth/login, POST /api/auth/logout, and GET /api/auth/session
* using the central typed fetch client.
*/
@@ -27,3 +27,17 @@ export async function login(password: string): Promise<LoginResponse> {
export async function logout(): Promise<LogoutResponse> {
return api.post<LogoutResponse>(ENDPOINTS.authLogout, {});
}
/**
* Validate the current session with the backend.
*
* This is called on app mount to confirm the session is still valid on the
* server side, rather than relying solely on the cached sessionStorage flag.
*
* @param signal - Optional abort signal for request cancellation.
* @returns An object confirming the session is valid.
* @throws {ApiError} When the session is invalid or expired (401).
*/
export async function validateSession(signal?: AbortSignal): Promise<{ valid: boolean }> {
return api.get<{ valid: boolean }>(ENDPOINTS.authSession, signal);
}

View File

@@ -23,6 +23,7 @@ export const ENDPOINTS = {
// -------------------------------------------------------------------------
authLogin: "/auth/login",
authLogout: "/auth/logout",
authSession: "/auth/session",
// -------------------------------------------------------------------------
// Dashboard

View File

@@ -0,0 +1,35 @@
/**
* Session validation loading indicator.
*
* Displayed on app mount while the backend validates the session.
*/
import { makeStyles, Spinner } from "@fluentui/react-components";
import React from "react";
const useStyles = makeStyles({
container: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
width: "100%",
flexDirection: "column",
gap: "16px",
},
});
/**
* Loading indicator shown during session validation on app mount.
*
* @returns React element displaying a centered spinner and message.
*/
export function SessionValidationLoading(): React.JSX.Element {
const styles = useStyles();
return (
<div className={styles.container}>
<Spinner size="large" label="Validating session…" />
</div>
);
}

View File

@@ -0,0 +1,74 @@
/**
* Hook for validating the session on app mount.
*
* Calls the backend to confirm the session is still valid, rather than relying
* solely on cached sessionStorage. Handles network errors gracefully by not
* logging out — only explicit 401/403 responses trigger logout.
*/
import { useCallback, useEffect, useState } from "react";
import * as authApi from "../api/auth";
import { ApiError, isAuthError } from "../api/client";
export interface UseSessionValidationResult {
/** `true` when the validation check is in flight. */
isLoading: boolean;
/** An error if validation failed for a reason other than 401/403. */
error: Error | null;
}
/**
* Validate the session on mount.
*
* This hook is called once during app initialization to confirm the session
* is valid on the backend. It handles three cases:
*
* 1. **Valid session (200)** — `onSessionValid()` is called.
* 2. **Invalid session (401/403)** — `onSessionExpired()` is called to log out.
* 3. **Network error** — `onNetworkError()` is called. The user is not logged out
* because the backend may be temporarily unreachable.
*
* @param onSessionValid - Callback when session is confirmed valid.
* @param onSessionExpired - Callback when session is invalid (401/403).
* @param onNetworkError - Callback when a network error occurs.
* @returns An object with `isLoading` and `error` states.
*/
export function useSessionValidation(
onSessionValid: () => void,
onSessionExpired: () => void,
onNetworkError?: (error: Error) => void,
): UseSessionValidationResult {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const validate = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
await authApi.validateSession();
onSessionValid();
} catch (err) {
if (err instanceof ApiError && isAuthError(err)) {
// Explicit 401/403 — session is invalid or expired.
onSessionExpired();
} else if (err instanceof Error) {
// Network error or other issue — don't log out.
setError(err);
onNetworkError?.(err);
} else {
const unknownError = new Error("Unknown error during session validation");
setError(unknownError);
onNetworkError?.(unknownError);
}
} finally {
setIsLoading(false);
}
}, [onSessionValid, onSessionExpired, onNetworkError]);
useEffect(() => {
void validate();
}, [validate]);
return { isLoading, error };
}

View File

@@ -11,12 +11,18 @@
* accepts the request (returns 2xx) and not authenticated when the backend
* rejects it (returns 401/403).
*
* On app mount, the provider validates the cached session state with the backend
* by calling `GET /api/auth/session`. While this check is in flight, a loading
* indicator is shown. If the backend returns 401/403, the session is cleared.
* If a network error occurs, the user is not logged out (the backend may be
* temporarily unreachable).
*
* The `isAuthenticated` state persists in `sessionStorage` to survive page
* refreshes within the browser tab but is cleared on tab close. The session
* cookie itself persists according to the backend's cookie settings.
*/
import {
import React, {
createContext,
useCallback,
useEffect,
@@ -26,6 +32,8 @@ import {
import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth";
import { setUnauthorizedHandler } from "../api/client";
import { SessionValidationLoading } from "../components/SessionValidationLoading";
import { useSessionValidation } from "../hooks/useSessionValidation";
// ---------------------------------------------------------------------------
// Context
@@ -56,6 +64,10 @@ const IS_AUTHENTICATED_KEY = "bangui_authenticated";
*
* Place this inside `<FluentProvider>` and `<BrowserRouter>` so all
* descendants can call `useAuth()`.
*
* On mount, validates the cached session with the backend. While validation
* is in progress, a loading indicator is shown. If validation fails with 401,
* the user is logged out. Network errors do not cause logout.
*/
export function AuthProvider({
children,
@@ -66,6 +78,7 @@ export function AuthProvider({
const stored = sessionStorage.getItem(IS_AUTHENTICATED_KEY);
return stored === "true";
});
const [isValidating, setIsValidating] = useState<boolean>(isAuthenticated);
const navigate = useNavigate();
const handleSessionExpired = useCallback((): void => {
@@ -74,6 +87,18 @@ export function AuthProvider({
navigate("/login", { replace: true });
}, [navigate]);
const handleSessionValid = useCallback((): void => {
// Session is already set from sessionStorage; no action needed.
}, []);
const handleValidationError = useCallback(
(error: Error): void => {
// Network error — log but don't logout.
console.warn("Session validation network error:", error);
},
[],
);
useEffect((): (() => void) => {
setUnauthorizedHandler((): void => {
handleSessionExpired();
@@ -83,6 +108,16 @@ export function AuthProvider({
};
}, [handleSessionExpired]);
const validationResult = useSessionValidation(
handleSessionValid,
handleSessionExpired,
handleValidationError,
);
useEffect(() => {
setIsValidating(validationResult.isLoading);
}, [validationResult.isLoading]);
const login = useCallback(async (password: string): Promise<void> => {
await authApi.login(password);
sessionStorage.setItem(IS_AUTHENTICATED_KEY, "true");
@@ -104,5 +139,10 @@ export function AuthProvider({
[isAuthenticated, login, logout],
);
// Show loading spinner while validating session on mount.
if (isValidating && isAuthenticated) {
return <SessionValidationLoading />;
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@@ -41,10 +41,12 @@ describe("AuthProvider", () => {
it("calls handler to clear auth state and redirect to /login", async () => {
sessionStorage.setItem("bangui_authenticated", "true");
let capturedHandler: (() => void) | null = null;
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation((handler) => {
capturedHandler = handler;
});
const capturedHandlers: Array<(() => void) | null> = [];
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation(
(handler: (() => void) | null) => {
capturedHandlers.push(handler);
},
);
render(
<FluentProvider theme={webLightTheme}>
@@ -59,6 +61,7 @@ describe("AuthProvider", () => {
);
// Invoke the handler that was captured during render
const capturedHandler = capturedHandlers[0];
if (typeof capturedHandler === "function") {
capturedHandler();
}