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:
@@ -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.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.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.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.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) | ⬜ | — |
|
| 1.10 | [Compose service block in trm/deploy](./phase-1-foundation/10-deploy-compose-block.md) | ⬜ | — |
|
||||||
|
|
||||||
|
|||||||
@@ -131,4 +131,20 @@ This is the right trade-off: logout that fails should _not_ leave the user appea
|
|||||||
|
|
||||||
## Done
|
## 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`** — `<LogoutButton>` 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`** — `<AuthBootstrap>` 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 `<LogoutButton />`. 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`.
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { useEffect, type ReactNode } from 'react';
|
|||||||
import { useRuntimeConfig } from '@/config/context';
|
import { useRuntimeConfig } from '@/config/context';
|
||||||
import { initDirectusClient } from './client';
|
import { initDirectusClient } from './client';
|
||||||
import { useAuthStore } from './store';
|
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
|
* Must render *inside* `<RuntimeConfigProvider>` (the runtime config gates
|
||||||
* its children, so by the time this mounts the config is loaded). Renders
|
* its children, so by the time this mounts the config is loaded). Renders
|
||||||
@@ -16,6 +18,7 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initDirectusClient(cfg.directusUrl);
|
initDirectusClient(cfg.directusUrl);
|
||||||
|
startCrossTabSync();
|
||||||
void useAuthStore.getState().initialize();
|
void useAuthStore.getState().initialize();
|
||||||
}, [cfg.directusUrl]);
|
}, [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 { AuthBootstrap } from './bootstrap';
|
||||||
export { getDirectus, initDirectusClient, useDirectus } from './client';
|
export { getDirectus, initDirectusClient, useDirectus } from './client';
|
||||||
export type { DirectusUser, Schema } 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 { useAuthStore } from './store';
|
||||||
export type { AuthState, AuthStatus } 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { useAuthStore } from '@/auth';
|
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';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/')({
|
export const Route = createFileRoute('/_authed/')({
|
||||||
@@ -14,8 +14,9 @@ function HomePage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Belt-and-braces: if the auth state flips to anonymous mid-session
|
// Belt-and-braces: if the auth state flips to anonymous mid-session
|
||||||
// (logout in another tab, server-side revocation), bounce to /login.
|
// (logout in another tab via cross-tab sync, server-side revocation),
|
||||||
// The `beforeLoad` gate only runs on navigation, not on store changes.
|
// bounce to /login. The `beforeLoad` gate only runs on navigation, not
|
||||||
|
// on store changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'anonymous') {
|
if (status === 'anonymous') {
|
||||||
void navigate({ to: '/login' });
|
void navigate({ to: '/login' });
|
||||||
@@ -26,20 +27,13 @@ function HomePage() {
|
|||||||
|
|
||||||
const displayName = user.first_name ?? user.email ?? user.id;
|
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 (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-4">
|
<div className="container mx-auto p-6 space-y-4">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">TRM</h1>
|
<h1 className="text-2xl font-semibold">TRM</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted-foreground">{displayName}</span>
|
<span className="text-sm text-muted-foreground">{displayName}</span>
|
||||||
<Button variant="outline" onClick={onSignOut}>
|
<LogoutButton />
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -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<typeof Button>;
|
||||||
|
|
||||||
|
export type LogoutButtonProps = Omit<ButtonProps, 'onClick' | 'disabled' | 'children'>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Button variant={variant} onClick={onClick} disabled={isLoggingOut} {...rest}>
|
||||||
|
{isLoggingOut ? 'Signing out…' : 'Sign out'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user