This commit is contained in:
2026-05-10 09:46:07 +12:00
parent cfc193b713
commit 2f2466ecac
81 changed files with 2571 additions and 413 deletions
+52 -28
View File
@@ -37,7 +37,6 @@ import type {
} from '$lib/types';
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000';
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
@@ -51,40 +50,62 @@ function getApiBaseUrl() {
}
}
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/+$/, '');
}
if (browser) {
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`;
const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim();
if (configuredBaseUrl) {
try {
const configuredUrl = new URL(configuredBaseUrl, window.location.origin);
// Keep browser API traffic same-origin by default. This avoids CORS,
// CSP `connect-src`, and cookie policy failures when the backend is
// reverse-proxied under `/api` on the same host.
if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) {
return '';
}
return configuredUrl.toString().replace(/\/+$/, '');
} catch {
return '';
}
}
return '';
}
return `http://127.0.0.1:${DEFAULT_API_PORT}`;
const defaultApiPort = env.PUBLIC_API_PORT || '8000';
return `http://127.0.0.1:${defaultApiPort}`;
}
function buildApiUrl(path: string) {
return `${getApiBaseUrl()}${path}`;
}
function getToken(auth: AuthMode) {
if (!browser) {
return null;
}
function getSessionFingerprint(auth: AuthMode) {
if (auth === 'client') {
return getStoredClientSession()?.token ?? null;
const session = getStoredClientSession();
return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : '';
}
if (auth === 'admin') {
return getStoredAdminSession()?.token ?? null;
const session = getStoredAdminSession();
return session ? `${session.role}:${session.email}` : '';
}
if (auth === 'manager') {
return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null;
const admin = getStoredAdminSession();
if (admin) {
return `${admin.role}:${admin.email}`;
}
const client = getStoredClientSession();
return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : '';
}
return null;
return '';
}
function resolveRequestUrl(path: string, fetcher: ApiFetch) {
if (fetcher !== fetch) {
return path;
}
return buildApiUrl(path);
}
function normalizeRequestError(error: unknown) {
@@ -107,9 +128,8 @@ function normalizeRequestError(error: unknown) {
async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
if (auth !== 'none') {
@@ -136,8 +156,8 @@ const inflightRequests = new Map<string, Promise<unknown>>();
const READ_CACHE_TTL_MS = 30_000;
function makeCacheKey(path: string, auth: AuthMode) {
const token = browser ? getToken(auth) ?? '' : '';
return `${auth}:${token.slice(-8)}:${path}`;
const sessionFingerprint = browser ? getSessionFingerprint(auth) : '';
return `${auth}:${sessionFingerprint}:${path}`;
}
async function cachedFetchJson<T>(
@@ -189,13 +209,12 @@ async function request<T>(
fetcher: ApiFetch = fetch
): Promise<T> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
const response = await fetcher(resolveRequestUrl(path, fetcher), {
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {})
},
credentials: 'include',
...options
});
@@ -218,6 +237,9 @@ async function request<T>(
// after the user creates or updates anything.
clearApiCache();
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
@@ -230,9 +252,8 @@ async function requestBlob(
fetcher: ApiFetch = fetch
): Promise<Blob> {
try {
const token = getToken(auth);
const response = await fetcher(buildApiUrl(path), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
const response = await fetcher(resolveRequestUrl(path, fetcher), {
credentials: 'include'
});
if (!response.ok) {
@@ -326,6 +347,9 @@ export const api = {
}),
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
clientLogout: () => request<void>('/api/auth/client/logout', { method: 'POST' }, 'client'),
adminLogout: () => request<void>('/api/auth/admin/logout', { method: 'POST' }, 'admin'),
internalLogout: () => request<void>('/api/access/logout', { method: 'POST' }, 'client'),
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',