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>
This commit is contained in:
2026-04-30 20:15:26 +02:00
parent ac53a56ae7
commit 69d32bfbe9
5 changed files with 169 additions and 35 deletions

View File

@@ -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<AuthContextValue | null>(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<AuthContextValue | null>(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
}
}
}, []);

View File

@@ -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(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/private"]}>
<AuthProvider>
<Routes>
<Route path="*" element={<CurrentLocation />} />
</Routes>
</AuthProvider>
</MemoryRouter>
</FluentProvider>,
);
// 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(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/private"]}>
<AuthProvider>
<Routes>
<Route path="*" element={<CurrentLocation />} />
</Routes>
</AuthProvider>
</MemoryRouter>
</FluentProvider>,
);
// 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(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/private"]}>
<AuthProvider>
<Routes>
<Route path="*" element={<CurrentLocation />} />
</Routes>
</AuthProvider>
</MemoryRouter>
</FluentProvider>,
);
// Verify the BroadcastChannel was created
expect(global.BroadcastChannel).toHaveBeenCalledWith("bangui_auth");
});
});