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.
51 lines
1.6 KiB
TypeScript
51 lines
1.6 KiB
TypeScript
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]);
|
|
}
|