diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8d7093c..1cb1828 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -56,7 +56,7 @@ These rules govern every task. Any deviation must be discussed and documented as | 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | 🟩 | `38fe2e3` | | 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | 🟩 | `7215cb5` | | 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | 🟩 | `f4a5e5b` | -| 1.8 | [Logout flow](./phase-1-foundation/08-logout-flow.md) | ⬜ | — | +| 1.8 | [Logout flow](./phase-1-foundation/08-logout-flow.md) | 🟩 | `PENDING_SHA` | | 1.9 | [Gitea CI + Dockerfile + nginx static serve](./phase-1-foundation/09-gitea-ci-and-dockerfile.md) | ⬜ | — | | 1.10 | [Compose service block in trm/deploy](./phase-1-foundation/10-deploy-compose-block.md) | ⬜ | — | diff --git a/.planning/phase-1-foundation/08-logout-flow.md b/.planning/phase-1-foundation/08-logout-flow.md index 9ae69f3..9f154b4 100644 --- a/.planning/phase-1-foundation/08-logout-flow.md +++ b/.planning/phase-1-foundation/08-logout-flow.md @@ -131,4 +131,20 @@ This is the right trade-off: logout that fails should _not_ leave the user appea ## Done -(Filled in when the task lands.) +- **`src/auth/logout.ts`** — `performLogout({ queryClient, navigate? })` orchestration helper. Calls `useAuthStore.getState().logout()` (which swallows server-call failures and forces local state to anonymous), then `queryClient.clear()` (so the next user doesn't see cached data), then navigates to `/login` if a navigate function was passed. +- **`src/auth/cross-tab-sync.ts`** — `startCrossTabSync()`. Subscribes to the auth store and bumps a `trm-auth-version` localStorage key on status transitions. Listens for storage events and re-runs `initialize()` when another tab bumps the key. Idempotent (HMR-safe) via a module-level `started` flag. Gracefully no-ops if `localStorage` is unavailable (private browsing, quota). +- **`src/ui/components/logout-button.tsx`** — `` component wraps the shadcn `Button`. Pass-through props (variant, size, etc.) but locks `onClick` / `disabled` / `children` so callers can't override the orchestration. Local `isLoggingOut` state shows "Signing out…" while the call is in flight; double-click guard. +- **`src/auth/bootstrap.tsx`** — `` updated to call `startCrossTabSync()` alongside `initDirectusClient()` and `initialize()`. Single mount point for all auth-side wiring. +- **`src/auth/index.ts`** — re-exports `performLogout` and `startCrossTabSync`. Alphabetised the exports. +- **`src/routes/_authed/index.tsx`** — replaced the inline button with ``. The page's existing `useEffect` on the `'anonymous'` status transition still handles the navigation when the cross-tab sync fires (no separate code path needed). + +**Bonus from this task** — caught a missing `"strict": true` in `tsconfig.app.json`. TanStack Router emits a `"strictNullChecks must be enabled"` conditional-type error in editors when strict mode is off; CI's `tsc -b` was lenient. Added `strict: true` (along with the existing strict-adjacent flags); typecheck still green. + +**Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. Bundle: 353KB main + same per-route chunks as 1.7 (login 37KB, _authed 1.4KB grew slightly with the LogoutButton). No measurable bundle impact from the logout orchestration. + +**Browser smoke pending:** +1. Sign in. Click "Sign out". Watch button show "Signing out…", then redirect to `/login`. Hard refresh on `/login` stays on `/login`. +2. Sign in in tab A. Open tab B (same SPA). Sign out in tab A. Tab B's home page should redirect to `/login` on next interaction (or immediately, depending on whether the storage event triggers re-render). +3. With DevTools "Network: offline", click sign out. Should still navigate to `/login` (server call fails, local state forced to anonymous). + +Landed in `PENDING_SHA`. diff --git a/src/auth/bootstrap.tsx b/src/auth/bootstrap.tsx index e96c26b..22650db 100644 --- a/src/auth/bootstrap.tsx +++ b/src/auth/bootstrap.tsx @@ -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* `` (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]); diff --git a/src/auth/cross-tab-sync.ts b/src/auth/cross-tab-sync.ts new file mode 100644 index 0000000..607abd1 --- /dev/null +++ b/src/auth/cross-tab-sync.ts @@ -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(); + }); +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 244695d..1c5fa54 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -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'; diff --git a/src/auth/logout.ts b/src/auth/logout.ts new file mode 100644 index 0000000..7e6327c --- /dev/null +++ b/src/auth/logout.ts @@ -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; + +/** + * 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 { + await useAuthStore.getState().logout(); + opts.queryClient.clear(); + if (opts.navigate) { + await opts.navigate({ to: '/login', replace: true }); + } +} diff --git a/src/routes/_authed/index.tsx b/src/routes/_authed/index.tsx index 3a184f7..059337a 100644 --- a/src/routes/_authed/index.tsx +++ b/src/routes/_authed/index.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { useAuthStore } from '@/auth'; -import { Button } from '@/ui/primitives/button'; +import { LogoutButton } from '@/ui/components/logout-button'; import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'; export const Route = createFileRoute('/_authed/')({ @@ -14,8 +14,9 @@ function HomePage() { const navigate = useNavigate(); // Belt-and-braces: if the auth state flips to anonymous mid-session - // (logout in another tab, server-side revocation), bounce to /login. - // The `beforeLoad` gate only runs on navigation, not on store changes. + // (logout in another tab via cross-tab sync, server-side revocation), + // bounce to /login. The `beforeLoad` gate only runs on navigation, not + // on store changes. useEffect(() => { if (status === 'anonymous') { void navigate({ to: '/login' }); @@ -26,20 +27,13 @@ function HomePage() { const displayName = user.first_name ?? user.email ?? user.id; - async function onSignOut() { - await useAuthStore.getState().logout(); - // The effect above will pick up the status change and navigate. - } - return (

TRM

{displayName} - +
diff --git a/src/ui/components/logout-button.tsx b/src/ui/components/logout-button.tsx new file mode 100644 index 0000000..d850023 --- /dev/null +++ b/src/ui/components/logout-button.tsx @@ -0,0 +1,33 @@ +import { useState, type ComponentProps } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { performLogout } from '@/auth/logout'; +import { Button } from '@/ui/primitives/button'; + +type ButtonProps = ComponentProps; + +export type LogoutButtonProps = Omit; + +export function LogoutButton({ variant = 'outline', ...rest }: LogoutButtonProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + async function onClick() { + if (isLoggingOut) return; + setIsLoggingOut(true); + try { + await performLogout({ queryClient, navigate }); + } catch { + // Even if the orchestration throws somewhere, the auth store is now + // anonymous and the user is signed out locally. Nothing useful to + // recover here. + } + } + + return ( + + ); +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 165b33d..5df20cf 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,6 +21,7 @@ }, /* Linting */ + "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true,