Files
spa/.planning/phase-1-foundation/05-directus-auth-client.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

10 KiB

Task 1.5 — Directus auth client (cookie mode + refresh)

Phase: 1 — Foundation Status: Not started Depends on: 1.4 Wiki refs: docs/wiki/entities/directus.md; docs/wiki/entities/react-spa.md §Auth pattern; docs/wiki/synthesis/processor-ws-contract.md §Auth handshake

Goal

Wire the Directus SDK with cookie-mode authentication: /auth/login issues an httpOnly refresh cookie + returns an access token (held in memory only); /auth/refresh rotates both; the SDK auto-refreshes on 401. Build a small Zustand auth store (user + status) so any component can react to login/logout. After this task, the auth machinery is functional but headless — the login page (1.6) and route guard (1.7) build on it.

Deliverables

  • src/auth/client.ts — typed Directus SDK client:

    import { authentication, createDirectus, rest } from '@directus/sdk';
    import { config } from '@/config/context'; // see "Bootstrapping" below
    
    export type DirectusUser = {
      id: string;
      email: string | null;
      role: string | null;
      first_name: string | null;
      last_name: string | null;
    };
    
    export type Schema = {
      // grow as collections become typed; for now an empty schema
    };
    
    export const directus = createDirectus<Schema>(config.directusUrl)
      .with(authentication('cookie', { credentials: 'include', autoRefresh: true }))
      .with(rest({ credentials: 'include' }));
    
    • The 'cookie' mode + credentials: 'include' is the SDK's contract for httpOnly-refresh-cookie + in-memory-access-token. autoRefresh: true enables the SDK's built-in 401 interceptor.
  • src/auth/store.ts — Zustand auth store:

    type AuthState =
      | { status: 'unknown' } // initial; before /users/me check
      | { status: 'anonymous' }
      | { status: 'authenticated'; user: DirectusUser }
      | { status: 'authenticating' }; // login in progress
    
    type AuthActions = {
      initialize(): Promise<void>; // call /users/me; set status accordingly
      login(email: string, password: string): Promise<{ ok: true } | { ok: false; error: string }>;
      logout(): Promise<void>;
      setUser(user: DirectusUser): void; // for after refresh sees a user-update
    };
    
    • initialize is called once at app boot from <RuntimeConfigProvider>'s post-load step. It tries /users/me; on 200, sets status: 'authenticated'; on 401/error, sets status: 'anonymous'.
  • src/auth/guard.tsuseRequireAuth() hook used by Phase 1.7's protected routes:

    export function useRequireAuth() {
      const status = useAuthStore((s) => s.status);
      const navigate = useNavigate(); // TanStack Router; lands in 1.7
      useEffect(() => {
        if (status === 'anonymous') navigate({ to: '/login' });
      }, [status, navigate]);
      return status;
    }
    
  • src/auth/index.ts — barrel re-exports.

  • src/main.tsx — call useAuthStore.getState().initialize() after the runtime config loads. (More precisely: the RuntimeConfigProvider's success path triggers initialize() exactly once.)

Specification

Why a Zustand auth store

The auth state is read in many places: route guards, the user-menu component, audit logs, the WS client (when 1.5 wires the WS). React Context cascades re-renders to all consumers on every change; Zustand's selector-based subscription means only components that read the changed slice re-render. For auth (small, infrequent changes) the difference is small in practice, but the pattern is the same one we'll use for live-position state in Phase 2 — staying consistent is the win.

Three reasons documented in docs/wiki/entities/react-spa.md §Auth pattern:

  1. No XSS exfiltration of long-lived credentials. The refresh cookie is httpOnly; JS can never read it.
  2. WS upgrade carries the cookie automatically. Same-origin → browser attaches it. No "send the JWT in the upgrade message" dance.
  3. Same pattern Directus already supports. No custom server work; just the right login mode.

The access token is held in memory by the SDK (in directus._authentication.tokens internally; not directly accessed by app code). It expires (default 15 min); the SDK's autoRefresh: true calls /auth/refresh on the next request that hits a 401, transparently.

Bootstrapping order

1. main.tsx renders <RuntimeConfigProvider> shell.
2. RuntimeConfigProvider fetches /config.json.
3. On success: imports the config singleton, runtime-config-driven createDirectus(...) is realisable.
4. RuntimeConfigProvider calls useAuthStore.getState().initialize().
5. initialize() calls directus.request(readMe()) (the SDK's /users/me wrapper).
6. On 200 → setUser, status: 'authenticated'.
7. On 401 → status: 'anonymous'.
8. Provider's children render — they can now read the auth store.

The order matters: createDirectus needs the runtime config to know what URL to hit. So the SDK client can't be a top-level module-level const; it has to be created after config loads. Two ways to handle this:

Option A: Lazy creation. getDirectusClient() returns the singleton, creating it on first call. The first call lands inside initialize(), after the config has resolved.

Option B: Module-level after config import. The config context exposes a directus instance that's only valid after the provider has loaded. Requires the import chain to defer.

Pick Option A. Cleaner — the singleton creation is explicitly tied to a function call rather than module-load order.

// src/auth/client.ts
let _client: ReturnType<typeof createClient> | null = null;

export function getDirectus() {
  if (!_client) {
    const cfg = useRuntimeConfig.getState(); // or pass via param; see below
    _client = createDirectus<Schema>(cfg.directusUrl)
      .with(authentication('cookie', { credentials: 'include', autoRefresh: true }))
      .with(rest({ credentials: 'include' }));
  }
  return _client;
}

If the runtime config hook isn't easily accessible from outside React, expose the config as a Zustand store too (or read directly from the loaded JSON via a non-React module). The simplest pragmatic answer: the config provider sets a module-level global on success, and getDirectus() reads it.

initialize() — the one-time login check

async initialize() {
  const directus = getDirectus();
  set({ status: 'authenticating' });  // tiny window; UX-invisible
  try {
    const user = await directus.request(readMe({
      fields: ['id', 'email', 'role', 'first_name', 'last_name'],
    }));
    set({ status: 'authenticated', user: user as DirectusUser });
  } catch (err) {
    // 401 from /users/me means no session. Anything else is unusual but treat the same.
    set({ status: 'anonymous' });
  }
}

The try/catch swallowing all errors is deliberate: at boot, a failure to read /users/me is the "anonymous" state. We don't surface boot-time errors here.

login(email, password)

async login(email, password) {
  const directus = getDirectus();
  set({ status: 'authenticating' });
  try {
    await directus.login(email, password, { mode: 'cookie' });
    // After login the SDK has the access token; re-fetch user.
    const user = await directus.request(readMe({ fields: [...] }));
    set({ status: 'authenticated', user: user as DirectusUser });
    return { ok: true };
  } catch (err) {
    set({ status: 'anonymous' });
    return { ok: false, error: humanizeAuthError(err) };
  }
}

humanizeAuthError maps Directus's error codes (INVALID_CREDENTIALS, INVALID_OTP, etc.) to user-friendly strings. Default for unknown: "Login failed. Please try again."

logout()

async logout() {
  const directus = getDirectus();
  try {
    await directus.logout();
  } catch {
    // Ignore — even if logout fails server-side, clear local state.
  }
  set({ status: 'anonymous' });
  // 1.8 builds on this: also invalidate the TanStack Query cache.
}

What this task does NOT do

  • No login page. That's 1.6.
  • No route guards wired into routes. That's 1.7.
  • No automatic redirect on logout. The component calling logout() decides where to navigate. 1.8 wires the actual logout button into the app shell.
  • No "remember me." Directus's refresh cookie is the persistence; the SPA doesn't add another layer.

Acceptance criteria

  • pnpm typecheck, pnpm lint, pnpm format:check clean.
  • In a browser console (with Directus running locally + a known user seeded): useAuthStore.getState().login('admin@example.com', 'password') resolves to { ok: true } and useAuthStore.getState().status === 'authenticated'.
  • After login, document.cookie shows nothing (refresh cookie is httpOnly — invisible to JS, as intended).
  • After login, useAuthStore.getState().user is the expected user record.
  • useAuthStore.getState().logout() resolves; status returns to 'anonymous'.
  • Wrong credentials produce { ok: false, error: 'Invalid email or password.' } (or similar humanised string).
  • Network error during login resolves to { ok: false, error: ... } — no uncaught exception bubbling to the React error boundary.

Risks / open questions

  • @directus/sdk cookie mode quirks. The SDK changed cookie-mode handling between 11.0 and 11.x. Verify the exact API against the version installed in 1.2 (pnpm list @directus/sdk). Adjust the authentication('cookie', ...) call signature if needed.
  • CSRF. Directus's cookie mode includes CSRF protection (the access token in the response body is the anti-CSRF measure — readable by JS, not by a third-party form post). The SDK handles this; we don't add custom CSRF headers.
  • Session persistence across reloads. The httpOnly refresh cookie persists across page reloads; on each load initialize() calls /users/me, which does a transparent refresh if needed, and the user is "still logged in." Verify in the integration smoke (1.6 task acceptance).
  • Lazy getDirectus() and HMR. During Vite dev HMR, the module reloads but the singleton survives (it's in a closure). If the config changes mid-session this becomes stale; acceptable trade-off — config rarely changes in dev.

Done

(Filled in when the task lands.)