Files
spa/.planning/phase-1-foundation/08-logout-flow.md
T
julian 26e059fc20 feat: planning structure + task 1.2 stack rounding-out
Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
2026-05-02 18:41:54 +02:00

7.0 KiB

Task 1.8 — Logout flow

Phase: 1 — Foundation Status: Not started Depends on: 1.7 Wiki refs: docs/wiki/entities/react-spa.md

Goal

Wire the "Sign out" button rendered by 1.7's home page to a real logout flow: call useAuthStore.logout(), invalidate the TanStack Query cache, navigate to /login. Small task, but worth its own file because it touches three subsystems and has subtle requirements (cache invalidation, race with concurrent calls).

After this task the Phase 1 happy path is end-to-end: log in → see home → log out → back to login.

Deliverables

  • src/auth/logout.ts — orchestration helper:

    export async function performLogout(opts: { queryClient: QueryClient; navigate: NavigateFn }) {
      await useAuthStore.getState().logout();
      opts.queryClient.clear();
      await opts.navigate({ to: '/login', replace: true });
    }
    
  • src/ui/components/logout-button.tsx — small shadcn Button wrapping the call:

    export function LogoutButton({ variant = 'outline' }: Props) {
      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 logout fails, the store state is now anonymous; the navigate has happened.
          // Nothing useful to do beyond logging.
        }
      }
    
      return (
        <Button variant={variant} onClick={onClick} disabled={isLoggingOut}>
          {isLoggingOut ? 'Signing out…' : 'Sign out'}
        </Button>
      );
    }
    
  • src/routes/_authed/index.tsx updated to use <LogoutButton /> instead of the placeholder button from 1.7.

  • Cross-tab logout handling — src/auth/cross-tab-sync.ts:

    • Subscribes to window.addEventListener('storage', ...) for a trm-auth-version key.
    • On change, re-runs useAuthStore.getState().initialize() to pick up the new state.
    • Mounted once at app boot from <RuntimeConfigProvider>'s success path (alongside the initial initialize() call).

Specification

Why clear the query cache on logout

After logout, the next user (someone else logging in on the same browser) shouldn't see cached data the previous user pulled. TanStack Query's cache is keyed by query keys, not by user identity — without an explicit clear, the next login would briefly show stale data while refetches resolve. queryClient.clear() removes everything; subsequent queries will refetch fresh.

The Zustand auth store also resets to 'anonymous'. No user data leaks across sessions in process memory.

Why a separate orchestration helper

Three things have to happen in order: server-side logout, client-side cache clear, navigation. Wrapping them in a function (performLogout) means:

  1. Components call one thing.
  2. The order is documented in code, not in the component.
  3. If we ever add another step (e.g. closing the live WS in Phase 2), it lands in one place.

Cross-tab sync

Pattern: when a user logs out in one tab, other tabs should also lose their session. Two ways:

Option A: BroadcastChannel. Modern API; the auth store posts on logout/login; other tabs listen. Cleaner.

Option B: localStorage + storage event. Older but universal. Write a "version" key on auth events; other tabs see the storage event and re-init.

Pick B for now. Slightly broader browser support; simpler. Migrate to BroadcastChannel later if it becomes preferred.

// src/auth/cross-tab-sync.ts
const VERSION_KEY = 'trm-auth-version';

export function startCrossTabSync() {
  // After every login/logout in this tab, bump the version so other tabs see a storage event.
  useAuthStore.subscribe((state, prev) => {
    if (state.status !== prev.status) {
      localStorage.setItem(VERSION_KEY, String(Date.now()));
    }
  });

  // React to bumps from other tabs.
  window.addEventListener('storage', (ev) => {
    if (ev.key !== VERSION_KEY) return;
    useAuthStore.getState().initialize();
  });
}

Worth flagging a subtlety: writing to localStorage doesn't trigger the storage event in the same tab. So this works exactly right — bump in tab A, tab B sees it.

What logout does NOT do

  • No "Are you sure?" confirmation. Click and out. Race operators clicking accidentally is unlikely in stage day operation; the nuisance of a confirmation modal isn't worth the marginal protection.
  • No server-side session enumeration / kill all sessions. Directus's /auth/logout invalidates the current session's refresh token. Other concurrent sessions (other browsers, other devices) keep working. If a "log out everywhere" feature is needed, that's a Phase 4 idea.
  • No analytics event. No analytics platform yet.

What happens if directus.logout() fails

useAuthStore.logout() (from 1.5) already swallows the error and forces local state to 'anonymous'. So the user is "logged out from the SPA" even if the server call failed (e.g. network outage). The refresh cookie may still be valid server-side, but without local state to replay it the SPA can't reuse it. On next login attempt, Directus may overwrite or invalidate the dangling cookie naturally.

This is the right trade-off: logout that fails should not leave the user appearing logged in.

Acceptance criteria

  • pnpm typecheck, pnpm lint, pnpm format:check clean.
  • Click "Sign out" → button shows "Signing out…" briefly → SPA navigates to /login → refresh cookie is gone (verifiable: refreshing the page now stays on /login instead of going to /).
  • Logging in twice (in two tabs) and logging out in tab A → tab B's auth state transitions to 'anonymous' (verifiable by watching the route gate redirect tab B to /login on next interaction).
  • After logout, opening the React Query devtools shows an empty cache.
  • Logout while offline (DevTools "Network: offline"): button completes, navigates to /login, no UI hang. Refreshing the page stays on /login.
  • Double-click on the logout button doesn't cause a duplicate /auth/logout call (the isLoggingOut flag guards).

Risks / open questions

  • Refresh-cookie zombie. If directus.logout() fails server-side, the refresh cookie may still be valid. On next page load, initialize() calls /users/me, which triggers a silent refresh, and the user is logged back in transparently. To force complete logout, the SPA should also issue a request to clear the cookie locally before navigating. Test this against stage and document the behaviour.
  • Cross-tab sync race. If tab A logs out and tab B is mid-request, tab B's request might 401 mid-flight. Handle gracefully: TanStack Query's default error handling shows a toast / retry; the route gate then redirects to login. Acceptable for v1.
  • Multiple BroadcastChannel listeners on hot-reload. During Vite dev HMR, startCrossTabSync() may run multiple times. Guard with a module-level boolean to ensure single registration.

Done

(Filled in when the task lands.)