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:
2026-05-02 18:24:38 +02:00
parent 8e38f69205
commit d3ccdded23
9 changed files with 137 additions and 15 deletions
+4 -1
View File
@@ -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]);
+44
View File
@@ -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
View File
@@ -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';
+29
View File
@@ -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 });
}
}
+5 -11
View File
@@ -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 (
<div className="container mx-auto p-6 space-y-4">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">TRM</h1>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{displayName}</span>
<Button variant="outline" onClick={onSignOut}>
Sign out
</Button>
<LogoutButton />
</div>
</header>
<Card>
+33
View File
@@ -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>
);
}