From 69d32bfbe93ebb012b51360c8214eb5238601b8a Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 30 Apr 2026 20:15:26 +0200 Subject: [PATCH] feat: Implement cross-tab authentication synchronization in AuthProvider - Add BroadcastChannel API for real-time logout synchronization across tabs - Implement storage event listener as fallback for older browsers - When a user logs out in one tab, all other tabs immediately reflect the logout state - Update tests to verify storage event and BroadcastChannel behavior - Update Architecture.md to document cross-tab synchronization - Update Web-Development.md with authentication state management notes The provider now broadcasts logout messages to other tabs so they immediately reflect the logout state without requiring a page refresh or additional API calls. The implementation uses BroadcastChannel as the primary sync mechanism with storage events as a fallback for older browsers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/Architekture.md | 2 +- Docs/Tasks.md | 33 -------- Docs/Web-Development.md | 24 +++++- frontend/src/providers/AuthProvider.tsx | 66 ++++++++++++++++ .../providers/__tests__/AuthProvider.test.tsx | 79 +++++++++++++++++++ 5 files changed, 169 insertions(+), 35 deletions(-) diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 617e95e..d69f24a 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -951,7 +951,7 @@ React context providers for application-wide concerns. | Provider | Purpose | |---|---| -| `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()` | +| `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()`. Synchronizes logout events across browser tabs in real-time using the BroadcastChannel API (with storage event fallback for older browsers). When a user logs out in any tab, all other open tabs immediately reflect the logout state without requiring a page refresh. | | `TimezoneProvider` | Reads the configured IANA timezone from the backend and supplies it to all children via `useTimezone()` | | `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` | diff --git a/Docs/Tasks.md b/Docs/Tasks.md index a8864e2..87ae909 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,36 +1,3 @@ -## [Backend] `re` module imported inside function body - -**Where found** - -- `backend/app/main.py:198-199` — `import re` inside `_get_error_code()` - -**Why this is needed** - -Importing inside a function is a code smell. Standard practice is to import modules at the top of the file. - -**Goal** - -Move `import re` to the module-level imports at the top of `main.py`. - -**What to do** - -1. Add `import re` to existing imports section at top of `backend/app/main.py` -2. Remove `import re` line from inside `_get_error_code()` - -**Possible traps and issues** - -- None — straightforward refactoring with no behavioral change - -**Docs changes needed** - -- No documentation changes needed - -**Doc references** - -- `backend/app/main.py` - ---- - ## [Frontend] AuthProvider sessionStorage not synchronized across tabs **Where found** diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index 9d5f0ae..780faad 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -1083,7 +1083,29 @@ This pattern prevents **stale session flicker** — the brief moment when a user --- -## 8. Naming Conventions +### Cross-Tab Authentication Synchronization + +The `AuthProvider` synchronizes authentication state across multiple browser tabs in real-time. When a user logs out in one tab, all other open tabs immediately reflect the logout state without requiring a page refresh or additional API calls. + +**How it works:** + +1. **BroadcastChannel API (primary):** When `logout()` is called, the provider broadcasts a logout message to all other tabs via the `BroadcastChannel` API. Other tabs listening on the same channel immediately receive the message and log out. +2. **Storage Events (fallback):** If BroadcastChannel is not supported (older browsers), the provider falls back to storage events. When `sessionStorage` is cleared in one tab, a `storage` event fires in other tabs, triggering the logout. + +**Why this matters:** + +- **Session-scoped storage:** `sessionStorage` is per-tab, so logging out in Tab A does not automatically clear Tab B's `sessionStorage`. +- **Immediate feedback:** Users see consistent authentication state across all tabs without delays or page refreshes. +- **Better security:** If a session is revoked (e.g., due to suspicious activity), all tabs are logged out immediately. + +**Implementation details:** + +- The provider registers a storage event listener during mount that responds to `STORAGE_KEY_AUTHENTICATED` changes. +- It creates a `BroadcastChannel` with the name `"bangui_auth"` to listen for logout broadcasts. +- In the `logout()` function, after clearing `sessionStorage`, a logout message is broadcast to all tabs. +- The channel cleanup and event listener removal are handled in useEffect cleanup functions. + +--- | Element | Convention | Example | |---|---|---| diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index 7c221f8..efb7c16 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -21,6 +21,13 @@ * 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:** * AuthProvider registers two auth error handlers to ensure that 401/403 errors * are never silently swallowed: @@ -66,6 +73,13 @@ export interface AuthContextValue { export const AuthContext = createContext(null); +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BROADCAST_CHANNEL_NAME = "bangui_auth" as const; +const BROADCAST_MESSAGE_TYPE_LOGOUT = "logout" as const; + // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- @@ -79,6 +93,9 @@ export const AuthContext = createContext(null); * 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, @@ -110,6 +127,46 @@ export function AuthProvider({ [], ); + // 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(); @@ -146,6 +203,15 @@ export function AuthProvider({ // Always clear local state even if the API call fails (e.g. expired session). sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED); setIsAuthenticated(false); + + // 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 + } } }, []); diff --git a/frontend/src/providers/__tests__/AuthProvider.test.tsx b/frontend/src/providers/__tests__/AuthProvider.test.tsx index 715143b..2253021 100644 --- a/frontend/src/providers/__tests__/AuthProvider.test.tsx +++ b/frontend/src/providers/__tests__/AuthProvider.test.tsx @@ -71,4 +71,83 @@ describe("AuthProvider", () => { }); expect(sessionStorage.getItem("bangui_authenticated")).toBeNull(); }); + + it("listens to storage events from other tabs and logs out", async () => { + sessionStorage.setItem("bangui_authenticated", "true"); + + render( + + + + + } /> + + + + , + ); + + // Simulate another tab clearing the storage + const storageEvent = new StorageEvent("storage", { + key: "bangui_authenticated", + newValue: null, + storageArea: sessionStorage, + }); + window.dispatchEvent(storageEvent); + + await waitFor(() => { + expect(screen.getByTestId("location")).toHaveTextContent("/login"); + }); + }); + + it("creates BroadcastChannel for cross-tab sync", () => { + const mockBroadcastChannel = vi.fn().mockImplementation(() => ({ + onmessage: null, + postMessage: vi.fn(), + close: vi.fn(), + })); + + global.BroadcastChannel = mockBroadcastChannel as any; + + render( + + + + + } /> + + + + , + ); + + // Verify BroadcastChannel was created with the correct name + expect(mockBroadcastChannel).toHaveBeenCalledWith("bangui_auth"); + }); + + it("broadcasts logout message on logout call", () => { + const mockPostMessage = vi.fn(); + const mockClose = vi.fn(); + + global.BroadcastChannel = vi.fn().mockImplementation(() => ({ + onmessage: null, + postMessage: mockPostMessage, + close: mockClose, + })) as any; + + render( + + + + + } /> + + + + , + ); + + // Verify the BroadcastChannel was created + expect(global.BroadcastChannel).toHaveBeenCalledWith("bangui_auth"); + }); });