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).
9.7 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 shadcnButtonwrapping 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.tsxupdated 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 atrm-auth-versionkey. - 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 initialinitialize()call).
- Subscribes to
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:
- Components call one thing.
- The order is documented in code, not in the component.
- 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/logoutinvalidates 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:checkclean.- Click "Sign out" → button shows "Signing out…" briefly → SPA navigates to
/login→ refresh cookie is gone (verifiable: refreshing the page now stays on/logininstead 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/loginon 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/logoutcall (theisLoggingOutflag 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. CallsuseAuthStore.getState().logout()(which swallows server-call failures and forces local state to anonymous), thenqueryClient.clear()(so the next user doesn't see cached data), then navigates to/loginif a navigate function was passed.src/auth/cross-tab-sync.ts—startCrossTabSync(). Subscribes to the auth store and bumps atrm-auth-versionlocalStorage key on status transitions. Listens for storage events and re-runsinitialize()when another tab bumps the key. Idempotent (HMR-safe) via a module-levelstartedflag. Gracefully no-ops iflocalStorageis unavailable (private browsing, quota).src/ui/components/logout-button.tsx—<LogoutButton>component wraps the shadcnButton. Pass-through props (variant, size, etc.) but locksonClick/disabled/childrenso callers can't override the orchestration. LocalisLoggingOutstate shows "Signing out…" while the call is in flight; double-click guard.src/auth/bootstrap.tsx—<AuthBootstrap>updated to callstartCrossTabSync()alongsideinitDirectusClient()andinitialize(). Single mount point for all auth-side wiring.src/auth/index.ts— re-exportsperformLogoutandstartCrossTabSync. Alphabetised the exports.src/routes/_authed/index.tsx— replaced the inline button with<LogoutButton />. The page's existinguseEffecton 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:
- Sign in. Click "Sign out". Watch button show "Signing out…", then redirect to
/login. Hard refresh on/loginstays on/login. - Sign in in tab A. Open tab B (same SPA). Sign out in tab A. Tab B's home page should redirect to
/loginon next interaction (or immediately, depending on whether the storage event triggers re-render). - With DevTools "Network: offline", click sign out. Should still navigate to
/login(server call fails, local state forced to anonymous).
Landed in 1ee339c.