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:
@@ -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
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user