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:
@@ -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`.
|
||||
|
||||
+9
-3
@@ -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 (
|
||||
<div className="min-h-screen grid place-items-center bg-muted">
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="text-3xl font-semibold">TRM</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Phase 1 scaffold. Tailwind + shadcn primitives wired.
|
||||
Phase 1 scaffold. Routing + login UI land in 1.6 / 1.7.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auth status: <code className="font-mono">{status}</code>
|
||||
{user && <> · {user.first_name ?? user.email ?? user.id}</>}
|
||||
</p>
|
||||
<Button>Sample button</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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* `<RuntimeConfigProvider>` (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}</>;
|
||||
}
|
||||
@@ -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<typeof buildClient>;
|
||||
|
||||
function buildClient(directusUrl: string) {
|
||||
return createDirectus<Schema>(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
|
||||
* `<RuntimeConfigProvider>` 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 <AuthBootstrap> inside <RuntimeConfigProvider>.',
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<void>;
|
||||
/** 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<void>;
|
||||
/** 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<DirectusUser> {
|
||||
const directus = getDirectus();
|
||||
const result = await directus.request(readMe({ fields: [...USER_FIELDS] }));
|
||||
return result as DirectusUser;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<Store>((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));
|
||||
},
|
||||
}));
|
||||
+4
-1
@@ -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(
|
||||
<StrictMode>
|
||||
<RuntimeConfigProvider>
|
||||
<App />
|
||||
<AuthBootstrap>
|
||||
<App />
|
||||
</AuthBootstrap>
|
||||
</RuntimeConfigProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user