Files
spa/.planning/phase-1-foundation/08-logout-flow.md
T
julian 8d0bc2bb1e feat(login): dev-only prefill from VITE_ADMIN_EMAIL/PASSWORD
When import.meta.env.DEV is true, the login form's email and password
fields are populated from the corresponding env vars. Shaves the manual
re-typing during dev iteration.

Production builds get empty strings regardless of build-time env values:
the prefill is gated on import.meta.env.DEV (which Vite replaces with
literal `false` at build time, so the surrounding ternary tree-shakes
and the env values can't bleed into the prod bundle even if accidentally
set in the build env).

Files:
- src/vite-env.d.ts (new): ImportMetaEnv augmentation with the four
  VITE_* vars we use (admin email/password, dev directus/processor URLs).
  Gives proper typing under strict mode.
- src/ui/pages/login.tsx: devDefaults computed once at module scope from
  import.meta.env. Form's defaultValues uses it.
- .env.example: documents VITE_ADMIN_EMAIL and VITE_ADMIN_PASSWORD with
  examples; notes the prod-ignore guarantee.
- .gitignore: adds *.env (defensive — complements the existing *.local
  pattern). .env.example stays committable (doesn't end in .env).
2026-05-02 18:48:04 +02:00

152 lines
9.7 KiB
Markdown

# 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:
```ts
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:
```tsx
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.
```ts
// 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
- **`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 `1ee339c`.