- 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>
243 lines
9.0 KiB
TypeScript
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>;
|
|
}
|