fix(auth): dedupe handler + error utils refactor

- Add 401/403 dedup guard to API client to prevent double logout
- Extract fetchError util: isAuthError + getErrorMessage
- AuthProvider uses new error utils, removes duplicate logic
- Remove completed task docs from Tasks.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-03 22:13:12 +02:00
parent dafe8d61e2
commit 7fcfc14199
4 changed files with 64 additions and 62 deletions

View File

@@ -135,6 +135,16 @@ export function isAuthError(err: unknown): err is ApiError {
*/
let unauthorizedHandler: (() => void) | null = null;
/**
* Deduplication guard: prevents the auth error handler from firing more than
* once per 401/403 response. Without this guard, both `setUnauthorizedHandler`
* (registered by AuthProvider) and `setAuthErrorHandler` (also registered by
* AuthProvider) would fire independently on the same 401, causing a double
* logout that triggers a React state mutation race and potential double
* navigation to the login page.
*/
let isLoggingOut: boolean = false;
/**
* Register a callback to be invoked when the API receives a 401/403 response.
* Typically called by the AuthProvider on mount.
@@ -145,6 +155,15 @@ export function setUnauthorizedHandler(handler: (() => void) | null): void {
unauthorizedHandler = handler;
}
/**
* Reset the isLoggingOut deduplication guard.
* Called when the user reaches the login page to allow future 401s to trigger
* the handler again.
*/
export function resetLogoutState(): void {
isLoggingOut = false;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
@@ -190,7 +209,10 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const body: string = await response.text();
if (response.status === 401 || response.status === 403) {
unauthorizedHandler?.();
if (!isLoggingOut) {
isLoggingOut = true;
unauthorizedHandler?.();
}
}
// Extract correlation ID from response header

View File

@@ -28,7 +28,7 @@
* 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:**
* **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
@@ -36,8 +36,15 @@
* - `setAuthErrorHandler()` in `utils/fetchError.ts` — handles auth errors that
* reach hooks and ensures deterministic error handling with fallback logging
*
* This dual-handler approach ensures auth errors are caught and handled at
* multiple layers, preventing silent error loss regardless of where the error occurs.
* 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, {
@@ -49,8 +56,8 @@ import React, {
} from "react";
import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth";
import { setUnauthorizedHandler } from "../api/client";
import { setAuthErrorHandler } from "../utils/fetchError";
import { setUnauthorizedHandler, resetLogoutState } 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";
@@ -114,6 +121,9 @@ export function AuthProvider({
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]);

View File

@@ -18,6 +18,19 @@ import { recordWarning, recordError } from "./telemetry";
*/
let authErrorHandler: ((error: FetchError) => void) | null = null;
/**
* Deduplication guard: prevents the auth error handler from firing more than
* once per 401/403 response. Without this guard, both `setUnauthorizedHandler`
* (registered by AuthProvider in api/client.ts) and `setAuthErrorHandler` (also
* registered by AuthProvider) would fire independently on the same 401, causing
* a double logout that triggers a React state mutation race and potential double
* navigation to the login page.
*
* This flag is set when the first handler fires, and reset when the user reaches
* the login page (handled in AuthProvider).
*/
let isLoggingOut: boolean = false;
/**
* Register a callback to be invoked when an auth error (401/403) is detected.
* Typically called by the AuthProvider on mount.
@@ -41,6 +54,15 @@ export function setAuthErrorHandler(handler: ((error: FetchError) => void) | nul
authErrorHandler = handler;
}
/**
* Reset the isLoggingOut deduplication guard.
* Called when the user reaches the login page to allow future 401s to trigger
* the handler again.
*/
export function resetLogoutState(): void {
isLoggingOut = false;
}
/**
* Extract user-friendly message from a FetchError.
*
@@ -116,7 +138,10 @@ export function handleFetchError(
);
if (authErrorHandler) {
authErrorHandler(fetchError);
if (!isLoggingOut) {
isLoggingOut = true;
authErrorHandler(fetchError);
}
} else {
// Fallback: log the auth error to ensure it remains actionable.
// This indicates that AuthProvider either hasn't mounted yet or