26e059fc20
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).
209 lines
10 KiB
Markdown
209 lines
10 KiB
Markdown
# 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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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.ts` — `useRequireAuth()` hook used by Phase 1.7's protected routes:
|
|
```ts
|
|
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.
|
|
|
|
### Why cookie mode (not bearer-token mode)
|
|
|
|
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.
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```ts
|
|
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)`
|
|
|
|
```ts
|
|
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()`
|
|
|
|
```ts
|
|
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.)
|