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:
@@ -951,7 +951,7 @@ React context providers for application-wide concerns.
|
|||||||
|
|
||||||
| Provider | Purpose |
|
| 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()` |
|
| `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` |
|
| `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## [Frontend] AuthProvider sessionStorage not synchronized across tabs
|
||||||
|
|
||||||
**Where found**
|
**Where found**
|
||||||
|
|||||||
@@ -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 |
|
| Element | Convention | Example |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
* refreshes within the browser tab but is cleared on tab close. The session
|
* refreshes within the browser tab but is cleared on tab close. The session
|
||||||
* cookie itself persists according to the backend's cookie settings.
|
* 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:**
|
* **Auth Error Handling:**
|
||||||
* AuthProvider registers two auth error handlers to ensure that 401/403 errors
|
* AuthProvider registers two auth error handlers to ensure that 401/403 errors
|
||||||
* are never silently swallowed:
|
* are never silently swallowed:
|
||||||
@@ -66,6 +73,13 @@ export interface AuthContextValue {
|
|||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
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
|
// Provider
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -79,6 +93,9 @@ export const AuthContext = createContext<AuthContextValue | null>(null);
|
|||||||
* On mount, validates the cached session with the backend. While validation
|
* On mount, validates the cached session with the backend. While validation
|
||||||
* is in progress, a loading indicator is shown. If validation fails with 401,
|
* is in progress, a loading indicator is shown. If validation fails with 401,
|
||||||
* the user is logged out. Network errors do not cause logout.
|
* 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({
|
export function AuthProvider({
|
||||||
children,
|
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) => {
|
useEffect((): (() => void) => {
|
||||||
setUnauthorizedHandler((): void => {
|
setUnauthorizedHandler((): void => {
|
||||||
handleSessionExpired();
|
handleSessionExpired();
|
||||||
@@ -146,6 +203,15 @@ export function AuthProvider({
|
|||||||
// Always clear local state even if the API call fails (e.g. expired session).
|
// Always clear local state even if the API call fails (e.g. expired session).
|
||||||
sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED);
|
sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED);
|
||||||
setIsAuthenticated(false);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -71,4 +71,83 @@ describe("AuthProvider", () => {
|
|||||||
});
|
});
|
||||||
expect(sessionStorage.getItem("bangui_authenticated")).toBeNull();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user