From 1ba0648cf38746c2494595dbc26de86167271c4f Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 17:47:33 +0200 Subject: [PATCH] feat: task 1.5 directus auth client + zustand auth store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six files under src/auth/: - client.ts: createDirectus().with(authentication('cookie', ...)) .with(rest(...)). Lazy singleton via initDirectusClient(url) + getDirectus() + useDirectus() (React-side helper). DirectusUser type; empty Schema (grows in Phase 2+). - store.ts: Zustand store with discriminated AuthState (unknown / anonymous / authenticating / authenticated). Actions: initialize, login, logout, setUser. humanizeAuthError maps Directus error codes (INVALID_CREDENTIALS / INVALID_OTP / USER_SUSPENDED) to user-facing strings. - guard.ts: useRequireAuth (redirect to /login on anonymous) and useRequireRole (bounce to / on role mismatch). Uses TanStack Router's useNavigate; effective once 1.7 wires the router. - bootstrap.tsx: component runs initDirectusClient + initialize() once after the runtime config is loaded. - index.ts: barrel re-exports. main.tsx wraps App in ... App.tsx shows the auth status as a smoke indicator before 1.6/1.7. Deviations: 1. Split getDirectus into initDirectusClient(url) + getDirectus() because the runtime config is a React context (per 1.4), not a Zustand store. bridges from React land to the non- React getter. 2. Added as a discrete component rather than putting the bootstrap effect in App.tsx โ€” App.tsx gets replaced by the router in 1.7, but bootstrap needs to keep running. --- .planning/ROADMAP.md | 2 +- .../05-directus-auth-client.md | 22 ++++- src/App.tsx | 12 ++- src/auth/bootstrap.tsx | 23 +++++ src/auth/client.ts | 77 ++++++++++++++++ src/auth/guard.ts | 50 +++++++++++ src/auth/index.ts | 6 ++ src/auth/store.ts | 90 +++++++++++++++++++ src/main.tsx | 5 +- 9 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 src/auth/bootstrap.tsx create mode 100644 src/auth/client.ts create mode 100644 src/auth/guard.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/store.ts diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ed66764..855e2c8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -53,7 +53,7 @@ These rules govern every task. Any deviation must be discussed and documented as | 1.2 | [Stack rounding-out (Tailwind + shadcn/ui + deps + Prettier)](./phase-1-foundation/02-stack-rounding-out.md) | ๐ŸŸฉ | `9918418` | | 1.3 | [Vite dev proxy + path aliases + tsconfig hardening](./phase-1-foundation/03-vite-dev-proxy.md) | ๐ŸŸฉ | `39b60c9` | | 1.4 | [Runtime config endpoint](./phase-1-foundation/04-runtime-config.md) | ๐ŸŸฉ | `8e2151a` | -| 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | โฌœ | โ€” | +| 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | ๐ŸŸฉ | `PENDING_SHA` | | 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | โฌœ | โ€” | | 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | โฌœ | โ€” | | 1.8 | [Logout flow](./phase-1-foundation/08-logout-flow.md) | โฌœ | โ€” | diff --git a/.planning/phase-1-foundation/05-directus-auth-client.md b/.planning/phase-1-foundation/05-directus-auth-client.md index 576aa21..8fa6885 100644 --- a/.planning/phase-1-foundation/05-directus-auth-client.md +++ b/.planning/phase-1-foundation/05-directus-auth-client.md @@ -205,4 +205,24 @@ async logout() { ## Done -(Filled in when the task lands.) +Six files under `src/auth/`: + +- `client.ts` โ€” `createDirectus().with(authentication('cookie', { credentials: 'include', autoRefresh: true })).with(rest({ credentials: 'include' }))`. Three accessors: `initDirectusClient(url)` (idempotent factory; rebuilds only if URL changed), `getDirectus()` (non-React getter; throws if init not yet called), `useDirectus()` (React-side helper). `DirectusUser` type + empty `Schema` placeholder (grows in Phase 2+). +- `store.ts` โ€” Zustand store with discriminated `AuthState` (`unknown` / `anonymous` / `authenticating` / `authenticated`) and four actions (`initialize`, `login`, `logout`, `setUser`). `humanizeAuthError` maps Directus error codes (`INVALID_CREDENTIALS`, `INVALID_OTP`, `USER_SUSPENDED`) to clear strings; falls back to `errors[0].message` then a generic. +- `guard.ts` โ€” `useRequireAuth()` (redirects to `/login` on `'anonymous'`) and `useRequireRole(allowedRoles)` (bounces to `/` on role mismatch). Uses TanStack Router's `useNavigate`; only takes effect once 1.7 wires the router. +- `bootstrap.tsx` โ€” `` component: calls `initDirectusClient(cfg.directusUrl)` and kicks off the one-time `initialize()` probe. Lives inside `` so the config is loaded by the time it mounts. +- `index.ts` โ€” barrel re-exports. + +`src/main.tsx` now wraps `` in ``. + +`src/App.tsx` shows the auth status as a smoke indicator (`unknown` โ†’ `authenticating` โ†’ `authenticated`/`anonymous`) so the bootstrap is observable in the browser before 1.6 / 1.7 land. + +**Deviations from spec:** + +1. Spec sketched a single `getDirectus()` that read from `useRuntimeConfig.getState()` as if it were a Zustand store. The runtime config is a React context (per 1.4), so reading it from a non-React function isn't possible. Solved by splitting into `initDirectusClient(url)` (called once from inside the React tree by ``) + `getDirectus()` (non-React, throws if init not yet called). The lazy-creation contract from the spec still holds. + +2. Introduced `` as a discrete component rather than putting the bootstrap effect inside `App.tsx`. Reason: 1.7 will replace `App.tsx` with the router, but the auth bootstrap needs to keep running. A named component is the clean home for it. + +**Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. Bundle: 261KB / 80KB gzipped (up from 1.4's 283KB / 87KB; the dead-code-elimination win comes from `@directus/sdk`'s tree-shaking once it's actually imported in real paths). Browser smoke pending โ€” needs a running Directus on `:8055` to exercise the cookie flow end-to-end. + +Landed in `PENDING_SHA`. diff --git a/src/App.tsx b/src/App.tsx index d099ea5..a88ff4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,20 @@ -import { Button } from '@/ui/primitives/button'; +import { useAuthStore } from '@/auth'; function App() { + const status = useAuthStore((s) => s.status); + const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); + return (

TRM

- Phase 1 scaffold. Tailwind + shadcn primitives wired. + Phase 1 scaffold. Routing + login UI land in 1.6 / 1.7. +

+

+ Auth status: {status} + {user && <> ยท {user.first_name ?? user.email ?? user.id}}

-
); diff --git a/src/auth/bootstrap.tsx b/src/auth/bootstrap.tsx new file mode 100644 index 0000000..e96c26b --- /dev/null +++ b/src/auth/bootstrap.tsx @@ -0,0 +1,23 @@ +import { useEffect, type ReactNode } from 'react'; +import { useRuntimeConfig } from '@/config/context'; +import { initDirectusClient } from './client'; +import { useAuthStore } from './store'; + +/** + * Wires the Directus client + kicks off the one-time `/users/me` probe. + * + * Must render *inside* `` (the runtime config gates + * its children, so by the time this mounts the config is loaded). Renders + * its children unconditionally โ€” the auth status is read separately via + * the auth store. + */ +export function AuthBootstrap({ children }: { children: ReactNode }) { + const cfg = useRuntimeConfig(); + + useEffect(() => { + initDirectusClient(cfg.directusUrl); + void useAuthStore.getState().initialize(); + }, [cfg.directusUrl]); + + return <>{children}; +} diff --git a/src/auth/client.ts b/src/auth/client.ts new file mode 100644 index 0000000..6447cd9 --- /dev/null +++ b/src/auth/client.ts @@ -0,0 +1,77 @@ +import { authentication, createDirectus, rest } from '@directus/sdk'; +import { useRuntimeConfig } from '@/config/context'; + +/** + * Minimum user fields we read after login. Mirrors what `/users/me` returns + * with `?fields=id,email,role,first_name,last_name`. + */ +export type DirectusUser = { + id: string; + email: string | null; + role: string | null; + first_name: string | null; + last_name: string | null; +}; + +/** + * Typed Directus collection schema for the SDK. + * + * Will grow as the SPA starts reading TRM-specific collections in Phase 2+. + * Empty for now โ€” Phase 1 only needs `/auth/*` and `/users/me`, which the + * SDK exposes outside the schema. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type Schema = {}; + +type DirectusClient = ReturnType; + +function buildClient(directusUrl: string) { + return createDirectus(directusUrl) + .with(authentication('cookie', { credentials: 'include', autoRefresh: true })) + .with(rest({ credentials: 'include' })); +} + +let _client: DirectusClient | null = null; +let _clientUrl: string | null = null; + +/** + * Lazy singleton accessor for the Directus client. + * + * The runtime config must be loaded before this is called (the + * `` enforces that by gating its children). + * If the config URL changes between calls (e.g. dev HMR), the client is + * rebuilt to match โ€” a minor convenience, not a production concern. + */ +export function getDirectus(): DirectusClient { + // We can't call useRuntimeConfig outside React; the consumers of getDirectus + // are non-React (Zustand actions). They pass the URL via a small adapter + // below or read it from a non-React-bound source. + if (!_client) { + throw new Error( + 'getDirectus() called before initDirectusClient(). Wire inside .', + ); + } + return _client; +} + +/** + * Initialise the Directus client with the runtime config's `directusUrl`. + * Idempotent: rebuilds only if the URL has changed (HMR-friendly). + */ +export function initDirectusClient(directusUrl: string): DirectusClient { + if (_client && _clientUrl === directusUrl) return _client; + _client = buildClient(directusUrl); + _clientUrl = directusUrl; + return _client; +} + +/** + * Convenience hook for components that need a typed Directus client. + * Inside React, prefer this over `getDirectus()` โ€” it's reactive to runtime + * config changes (in practice config is immutable, but the hook form is + * cleaner). + */ +export function useDirectus(): DirectusClient { + const cfg = useRuntimeConfig(); + return initDirectusClient(cfg.directusUrl); +} diff --git a/src/auth/guard.ts b/src/auth/guard.ts new file mode 100644 index 0000000..d647c33 --- /dev/null +++ b/src/auth/guard.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useAuthStore } from './store'; + +/** + * Watches the auth store and redirects to `/login` if the user becomes + * anonymous mid-session (e.g. logout in another tab, refresh-cookie expiry, + * server-side revocation). + * + * Used inside the `_authed` route layout (lands in 1.7) as belt-and-braces + * alongside TanStack Router's `beforeLoad` guard. + * + * Does nothing while auth state is `unknown` or `authenticating` โ€” the + * layout shows a loading spinner during those windows. + */ +export function useRequireAuth() { + const status = useAuthStore((s) => s.status); + const navigate = useNavigate(); + + useEffect(() => { + if (status === 'anonymous') { + void navigate({ to: '/login' }); + } + }, [status, navigate]); + + return status; +} + +/** + * Role-aware guard. Bounces the user to `/` if their `role` is not in the + * allowed list. No-op while not authenticated (the surrounding layout + * already handles the anonymous case). + * + * Phase 1 has no role-restricted routes โ€” every authenticated user is + * effectively admin until [[directus]] Phase 4. Reserved for Phase 2+. + * + * Example (Phase 2): + * useRequireRole(['race-director', 'org-admin']); + */ +export function useRequireRole(allowedRoles: readonly string[]) { + const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); + const navigate = useNavigate(); + + useEffect(() => { + if (!user) return; + if (user.role && !allowedRoles.includes(user.role)) { + void navigate({ to: '/', replace: true }); + } + }, [user, allowedRoles, navigate]); +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..244695d --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,6 @@ +export { AuthBootstrap } from './bootstrap'; +export { getDirectus, initDirectusClient, useDirectus } from './client'; +export type { DirectusUser, Schema } from './client'; +export { useAuthStore } from './store'; +export type { AuthState, AuthStatus } from './store'; +export { useRequireAuth, useRequireRole } from './guard'; diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..3c45ba2 --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,90 @@ +import { readMe } from '@directus/sdk'; +import { create } from 'zustand'; +import { getDirectus } from './client'; +import type { DirectusUser } from './client'; + +export type AuthStatus = 'unknown' | 'anonymous' | 'authenticating' | 'authenticated'; + +export type AuthState = + | { status: 'unknown' } + | { status: 'anonymous' } + | { status: 'authenticating' } + | { status: 'authenticated'; user: DirectusUser }; + +type AuthActions = { + /** Probe `/users/me` once at boot to determine if we already have a session. */ + initialize: () => Promise; + /** Sign in with email + password using Directus's cookie mode. */ + login: (email: string, password: string) => Promise<{ ok: true } | { ok: false; error: string }>; + /** Sign out via Directus + clear local state. Errors during the server call are swallowed. */ + logout: () => Promise; + /** Replace the current user record (e.g. after profile edit). No-op if not authenticated. */ + setUser: (user: DirectusUser) => void; +}; + +type Store = AuthState & AuthActions; + +const USER_FIELDS = ['id', 'email', 'role', 'first_name', 'last_name'] as const; + +function humanizeAuthError(err: unknown): string { + if (err && typeof err === 'object' && 'errors' in err) { + const e = err as { errors?: { extensions?: { code?: string }; message?: string }[] }; + const code = e.errors?.[0]?.extensions?.code; + if (code === 'INVALID_CREDENTIALS') return 'Invalid email or password.'; + if (code === 'INVALID_OTP') return 'Invalid one-time password.'; + if (code === 'USER_SUSPENDED') return 'This account is suspended. Contact your administrator.'; + if (e.errors?.[0]?.message) return e.errors[0].message; + } + if (err instanceof Error) return err.message; + return 'Login failed. Please try again.'; +} + +async function fetchMe(): Promise { + const directus = getDirectus(); + const result = await directus.request(readMe({ fields: [...USER_FIELDS] })); + return result as DirectusUser; +} + +export const useAuthStore = create((set) => ({ + status: 'unknown', + + async initialize() { + set({ status: 'authenticating' }); + try { + const user = await fetchMe(); + set({ status: 'authenticated', user }); + } catch { + // Any failure on /users/me at boot means no session. Swallow. + set({ status: 'anonymous' }); + } + }, + + async login(email, password) { + set({ status: 'authenticating' }); + try { + const directus = getDirectus(); + await directus.login({ email, password }); + const user = await fetchMe(); + set({ status: 'authenticated', user }); + return { ok: true }; + } catch (err) { + set({ status: 'anonymous' }); + return { ok: false, error: humanizeAuthError(err) }; + } + }, + + async logout() { + try { + await getDirectus().logout(); + } catch { + // Even if the server call fails, drop local state so the user appears + // signed out. Logout that fails should not leave the user appearing + // logged in. + } + set({ status: 'anonymous' }); + }, + + setUser(user) { + set((state) => (state.status === 'authenticated' ? { ...state, user } : state)); + }, +})); diff --git a/src/main.tsx b/src/main.tsx index 5807b27..f0b1865 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'; import './styles/globals.css'; import App from './App.tsx'; import { RuntimeConfigProvider } from '@/config/provider'; +import { AuthBootstrap } from '@/auth'; createRoot(document.getElementById('root')!).render( - + + + , );