1ba0648cf3
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.
91 lines
3.1 KiB
TypeScript
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));
|
|
},
|
|
}));
|