feat: task 1.8 logout flow + cross-tab sync + strict tsconfig
- src/auth/logout.ts: performLogout({ queryClient, navigate? })
orchestration. Calls store.logout(), clears query cache, navigates
to /login. Server-call failures swallowed; local state still flips
to anonymous.
- src/auth/cross-tab-sync.ts: startCrossTabSync(). Subscribes to the
auth store and bumps trm-auth-version in localStorage on status
transitions; listens for storage events and re-runs initialize()
when another tab bumps. Idempotent via module-level guard. No-ops
if localStorage is unavailable.
- src/ui/components/logout-button.tsx: <LogoutButton> wraps shadcn
Button. Pass-through props but locks onClick / disabled / children.
Local isLoggingOut for "Signing out..." indicator + double-click
guard.
- src/auth/bootstrap.tsx: <AuthBootstrap> now calls startCrossTabSync()
alongside the existing init steps. Single mount point.
- src/auth/index.ts: re-export performLogout + startCrossTabSync.
- src/routes/_authed/index.tsx: replaced the inline sign-out button
with <LogoutButton />. The existing useEffect on 'anonymous' status
handles navigation when cross-tab sync fires.
Bonus: tsconfig.app.json now has "strict": true. TanStack Router emits
a conditional-type error ("strictNullChecks must be enabled") in
editors when strict is off; tsc -b was lenient. Caught in 1.7's
review. Typecheck remained green after enabling.
This commit is contained in:
@@ -2,9 +2,11 @@ import { useEffect, type ReactNode } from 'react';
|
||||
import { useRuntimeConfig } from '@/config/context';
|
||||
import { initDirectusClient } from './client';
|
||||
import { useAuthStore } from './store';
|
||||
import { startCrossTabSync } from './cross-tab-sync';
|
||||
|
||||
/**
|
||||
* Wires the Directus client + kicks off the one-time `/users/me` probe.
|
||||
* Wires the Directus client, kicks off the one-time `/users/me` probe, and
|
||||
* registers cross-tab auth synchronisation.
|
||||
*
|
||||
* Must render *inside* `<RuntimeConfigProvider>` (the runtime config gates
|
||||
* its children, so by the time this mounts the config is loaded). Renders
|
||||
@@ -16,6 +18,7 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
initDirectusClient(cfg.directusUrl);
|
||||
startCrossTabSync();
|
||||
void useAuthStore.getState().initialize();
|
||||
}, [cfg.directusUrl]);
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useAuthStore } from './store';
|
||||
|
||||
/**
|
||||
* Cross-tab auth synchronisation via the `storage` event.
|
||||
*
|
||||
* When a user logs out (or in) in tab A, other tabs need to pick up the
|
||||
* new state without the user clicking around. The storage event fires on
|
||||
* every other tab when localStorage changes — bumping a version key in
|
||||
* tab A causes tabs B, C, ... to re-run the auth probe and converge.
|
||||
*
|
||||
* Pattern is one-way: this tab writes the version key on its own auth
|
||||
* transitions; storage events trigger `initialize()` to re-read state from
|
||||
* Directus. The actual auth state lives in cookies (server-issued), not in
|
||||
* localStorage — the version key is just a "something changed, re-check"
|
||||
* signal.
|
||||
*
|
||||
* Idempotent: safe to call multiple times (HMR-friendly).
|
||||
*/
|
||||
|
||||
const VERSION_KEY = 'trm-auth-version';
|
||||
let started = false;
|
||||
|
||||
export function startCrossTabSync(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
let lastStatus: string | null = null;
|
||||
|
||||
useAuthStore.subscribe((state) => {
|
||||
if (state.status === lastStatus) return;
|
||||
lastStatus = state.status;
|
||||
try {
|
||||
localStorage.setItem(VERSION_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// localStorage may be unavailable (private browsing, quota). Sync
|
||||
// silently no-ops; auth still works in this tab, just not cross-tab.
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('storage', (ev) => {
|
||||
if (ev.key !== VERSION_KEY) return;
|
||||
void useAuthStore.getState().initialize();
|
||||
});
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
export { AuthBootstrap } from './bootstrap';
|
||||
export { getDirectus, initDirectusClient, useDirectus } from './client';
|
||||
export type { DirectusUser, Schema } from './client';
|
||||
export { startCrossTabSync } from './cross-tab-sync';
|
||||
export { useRequireAuth, useRequireRole } from './guard';
|
||||
export { performLogout } from './logout';
|
||||
export { useAuthStore } from './store';
|
||||
export type { AuthState, AuthStatus } from './store';
|
||||
export { useRequireAuth, useRequireRole } from './guard';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import type { useNavigate } from '@tanstack/react-router';
|
||||
import { useAuthStore } from './store';
|
||||
|
||||
type NavigateFn = ReturnType<typeof useNavigate>;
|
||||
|
||||
/**
|
||||
* Orchestrates the full logout sequence:
|
||||
*
|
||||
* 1. Server-side logout (Directus invalidates the session cookie).
|
||||
* 2. Clear the TanStack Query cache so the next user doesn't see the
|
||||
* previous user's cached data.
|
||||
* 3. Navigate to `/login`.
|
||||
*
|
||||
* Server-call failures are swallowed — even if Directus is unreachable,
|
||||
* the local state is forced to `'anonymous'` (in `useAuthStore.logout`)
|
||||
* and the navigation still happens. Logout that fails should not leave
|
||||
* the user appearing logged in.
|
||||
*/
|
||||
export async function performLogout(opts: {
|
||||
queryClient: QueryClient;
|
||||
navigate?: NavigateFn;
|
||||
}): Promise<void> {
|
||||
await useAuthStore.getState().logout();
|
||||
opts.queryClient.clear();
|
||||
if (opts.navigate) {
|
||||
await opts.navigate({ to: '/login', replace: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user