Files
spa/src/auth/client.ts
T
julian a65ad428e6 fix(auth): resolve relative directus URL against window origin
The runtime config supports relative paths like /api so the SPA can run
same-origin in dev (Vite proxy) and stage/prod (Traefik). createDirectus
from @directus/sdk calls new URL(...) internally, which throws on
relative URLs. Resolve in toAbsoluteUrl() before handing to the SDK:

  '/api' -> 'http://localhost:5173/api' (dev)
  'https://api.example.com' -> passes through unchanged

Caught at runtime; typecheck/build don't surface this since the SDK
treats its url arg as a string.
2026-05-02 18:44:37 +02:00

93 lines
3.2 KiB
TypeScript

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>;
/**
* Resolve a (possibly relative) URL against the current page origin.
*
* Runtime config supports relative paths like `/api` because the SPA always
* runs same-origin to its backends. The Directus SDK, however, calls
* `new URL(...)` internally and requires an absolute URL — so we resolve
* before handing it over.
*
* - `'/api'` → `'http://localhost:5173/api'` (in dev)
* - `'https://api.example.com'` → `'https://api.example.com/'` (passes through)
*/
function toAbsoluteUrl(maybeRelative: string): string {
return new URL(maybeRelative, window.location.origin).toString();
}
function buildClient(directusUrl: string) {
return createDirectus<Schema>(toAbsoluteUrl(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);
}