feat: task 1.5 directus auth client + zustand auth store

Six files under src/auth/:
- client.ts: createDirectus<Schema>().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: <AuthBootstrap> component runs initDirectusClient +
  initialize() once after the runtime config is loaded.
- index.ts: barrel re-exports.

main.tsx wraps App in <RuntimeConfigProvider><AuthBootstrap>...
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. <AuthBootstrap> bridges from React land to the non-
   React getter.
2. Added <AuthBootstrap> 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.
This commit is contained in:
2026-05-02 17:47:33 +02:00
parent d2026ee83d
commit 1ba0648cf3
9 changed files with 281 additions and 6 deletions
+1 -1
View File
@@ -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) | ⬜ | — |
@@ -205,4 +205,24 @@ async logout() {
## Done
(Filled in when the task lands.)
Six files under `src/auth/`:
- `client.ts` — `createDirectus<Schema>().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` — `<AuthBootstrap>` component: calls `initDirectusClient(cfg.directusUrl)` and kicks off the one-time `initialize()` probe. Lives inside `<RuntimeConfigProvider>` so the config is loaded by the time it mounts.
- `index.ts` — barrel re-exports.
`src/main.tsx` now wraps `<App />` in `<RuntimeConfigProvider><AuthBootstrap><App /></AuthBootstrap></RuntimeConfigProvider>`.
`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 `<AuthBootstrap>`) + `getDirectus()` (non-React, throws if init not yet called). The lazy-creation contract from the spec still holds.
2. Introduced `<AuthBootstrap>` 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`.