Files
BanGUI/frontend/src/providers/AuthProvider.tsx
Lukas c8b48b5b65 fix(api): correlation ID survives HMR; fix endpoint template literal typos
- client.ts: store correlation ID in sessionStorage so HMR (module re-eval)
  does not generate a new ID mid-session; add clearSessionCorrelationId()
- endpoints.ts: fix 3 template literal trailing-quote bugs (missing ')' chars);
  replace template literals with string concat for encodeURIComponent calls
- AuthProvider.tsx: call clearSessionCorrelationId() on logout
- App.tsx: reorder ThemeProvider import before AuthProvider per PROVIDER_ORDER.md;
  indent Routes inside AuthProvider to match expected tree structure
- Tasks.md: update task status
- providerTreeOrder.test.tsx: add integration tests for provider nesting order

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 23:35:18 +02:00

243 lines
9.0 KiB
TypeScript

/**
* Authentication context and provider.
*
* Manages the user's authenticated state and exposes `login`, `logout`, and
* `isAuthenticated` through `useAuth()`.
*
* The authentication model is **cookie-based**: the backend sets a session
* cookie (`bangui_session`) on successful login, and the frontend automatically
* includes it in all requests via `credentials: "include"`. The backend is the
* authority on session validity; the frontend is authenticated when the backend
* 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.
*
* **Cross-Tab Synchronization:**
* The provider uses BroadcastChannel API to synchronize logout events across
* tabs in real-time. On logout, a message is broadcast to all other tabs so they
* immediately reflect the logout state without requiring a page refresh or API call.
* If the browser does not support BroadcastChannel, the storage event listener
* serves as a fallback (though it only fires when storage is changed in another tab).
*
* **Auth Error Handling & Deduplication:**
* AuthProvider registers two auth error handlers to ensure that 401/403 errors
* are never silently swallowed:
* - `setUnauthorizedHandler()` in `api/client.ts` — handles auth errors from the
* API client layer before they reach hooks
* - `setAuthErrorHandler()` in `utils/fetchError.ts` — handles auth errors that
* reach hooks and ensures deterministic error handling with fallback logging
*
* A module-level `isLoggingOut` flag in each file prevents double-firing: the
* first handler to fire sets its flag, and the second checks it before invoking
* its handler. Both flags are reset in `handleSessionExpired()` so future 401s
* can trigger the handlers again after the user reaches the login page.
*
* This dual-handler approach with deduplication ensures auth errors are caught
* at multiple layers, preventing silent error loss regardless of where the
* error occurs, while avoiding the React state mutation race caused by
* concurrent logout dispatches.
*/
import React, {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth";
import { setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
import { SessionValidationLoading } from "../components/SessionValidationLoading";
import { useSessionValidation } from "../hooks/useSessionValidation";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
export interface AuthContextValue {
/** `true` when the backend considers the session valid. */
isAuthenticated: boolean;
/** `true` while the session is being validated on app mount. */
isValidating: boolean;
/**
* Authenticate with the master password.
* Throws an `ApiError` on failure.
*/
login: (password: string) => Promise<void>;
/** Revoke the current session and clear local state. */
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BROADCAST_CHANNEL_NAME = "bangui_auth" as const;
const BROADCAST_MESSAGE_TYPE_LOGOUT = "logout" as const;
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/**
* Wraps the application and provides authentication state to all children.
*
* 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.
*
* Listens for cross-tab logout events via BroadcastChannel (or storage events
* as fallback) to immediately reflect logout in other tabs.
*/
export function AuthProvider({
children,
}: {
children: React.ReactNode;
}): React.JSX.Element {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
const stored = sessionStorage.getItem(STORAGE_KEY_AUTHENTICATED);
return stored === "true";
});
const [isValidating, setIsValidating] = useState<boolean>(isAuthenticated);
const navigate = useNavigate();
const handleSessionExpired = useCallback((): void => {
sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED);
setIsAuthenticated(false);
// Reset deduplication flags so future 401s can trigger handlers again.
resetLogoutState();
resetFetchErrorLogoutState();
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);
},
[],
);
// Handle storage events from other tabs (fallback sync mechanism)
useEffect((): (() => void) => {
const handleStorageChange = (event: StorageEvent): void => {
// Only respond to changes in our authentication key
if (event.key === STORAGE_KEY_AUTHENTICATED) {
// If the storage was cleared in another tab, logout this tab
if (event.newValue === null) {
handleSessionExpired();
}
}
};
window.addEventListener("storage", handleStorageChange);
return (): void => {
window.removeEventListener("storage", handleStorageChange);
};
}, [handleSessionExpired]);
// Handle BroadcastChannel messages for real-time cross-tab sync
useEffect((): (() => void) => {
let channel: BroadcastChannel | null = null;
try {
channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.onmessage = (event: MessageEvent): void => {
if (event.data?.type === BROADCAST_MESSAGE_TYPE_LOGOUT) {
handleSessionExpired();
}
};
} catch {
// BroadcastChannel not supported; storage event listener serves as fallback
}
return (): void => {
if (channel) {
channel.close();
}
};
}, [handleSessionExpired]);
useEffect((): (() => void) => {
setUnauthorizedHandler((): void => {
handleSessionExpired();
});
setAuthErrorHandler((): void => {
handleSessionExpired();
});
return (): void => {
setUnauthorizedHandler(null);
setAuthErrorHandler(null);
};
}, [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(STORAGE_KEY_AUTHENTICATED, "true");
setIsAuthenticated(true);
}, []);
const logout = useCallback(async (): Promise<void> => {
try {
await authApi.logout();
} finally {
// Always clear local state even if the API call fails (e.g. expired session).
sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED);
setIsAuthenticated(false);
clearSessionCorrelationId();
// Broadcast logout event to other tabs for immediate sync
try {
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.postMessage({ type: BROADCAST_MESSAGE_TYPE_LOGOUT });
channel.close();
} catch {
// BroadcastChannel not supported; storage event will fire on other tabs automatically
}
}
}, []);
const value = useMemo<AuthContextValue>(
() => ({ isAuthenticated, isValidating, login, logout }),
[isAuthenticated, isValidating, login, logout],
);
// Show loading spinner while validating session on mount.
if (isValidating && isAuthenticated) {
return <SessionValidationLoading />;
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}