Files
julian 1ba0648cf3 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.
2026-05-02 18:43:51 +02:00

91 lines
3.1 KiB
TypeScript

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));
},
}));