2026-06-17 17:06:30 -06:00
|
|
|
/**
|
|
|
|
|
* Auth endpoints. The 401-refresh/dedup behaviour lives in apiFetch; the auth
|
|
|
|
|
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
|
|
|
|
* a genuine failure to the caller.
|
|
|
|
|
*/
|
|
|
|
|
import { apiFetch } from '$lib/api/client';
|
|
|
|
|
import { getCsrfHeaders } from '$lib/api/csrf';
|
|
|
|
|
import type { AuthResponse, User } from '$lib/api/types';
|
|
|
|
|
|
|
|
|
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Probe the current session. Uses the raw `fetch` (NOT apiFetch) on purpose:
|
|
|
|
|
* a 401 here just means "not logged in" and must not trigger the global
|
|
|
|
|
* refresh-and-redirect (which would bounce the app in a refresh loop on the
|
|
|
|
|
* unauthenticated initial load). Returns null when unauthenticated.
|
|
|
|
|
*/
|
|
|
|
|
export async function fetchMe(): Promise<User | null> {
|
|
|
|
|
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
|
|
|
|
if (res.status === 401) return null;
|
|
|
|
|
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
|
|
|
|
|
return (await res.json()) as User;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Attempt a single token refresh (raw fetch, no interceptor). Returns whether
|
|
|
|
|
* it succeeded. Used by the startup probe; mid-session refresh is handled
|
|
|
|
|
* transparently by apiFetch for all other endpoints.
|
|
|
|
|
*/
|
|
|
|
|
export async function tryRefresh(): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/auth/refresh', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: '{}'
|
|
|
|
|
});
|
|
|
|
|
return res.ok;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
|
|
|
|
|
const res = await apiFetch('/api/auth/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ username: emailOrUsername, password })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(`login failed: ${res.status}`);
|
|
|
|
|
return (await res.json()) as AuthResponse;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
export interface OidcProviders {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
provider_name?: string;
|
|
|
|
|
password_login_enabled?: boolean;
|
|
|
|
|
authorize_endpoint?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Public OIDC provider info for the login page. */
|
|
|
|
|
export async function getOidcProviders(): Promise<OidcProviders> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/auth/oidc/providers');
|
|
|
|
|
if (!res.ok) return { enabled: false };
|
|
|
|
|
return (await res.json()) as OidcProviders;
|
|
|
|
|
} catch {
|
|
|
|
|
return { enabled: false };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AuthStatus {
|
|
|
|
|
initialized: boolean;
|
|
|
|
|
admin_count: number;
|
|
|
|
|
registration_allowed: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* System bootstrap probe. When `initialized === false` no admin exists yet and
|
|
|
|
|
* the login page must offer the first-run admin-setup flow. Raw `fetch` (NOT
|
|
|
|
|
* apiFetch): this is unauthenticated and a non-2xx must not bounce through the
|
|
|
|
|
* refresh interceptor. Defaults to "initialized" on any failure so a transient
|
|
|
|
|
* error never strands operators on the setup wizard.
|
|
|
|
|
*/
|
|
|
|
|
export async function getAuthStatus(): Promise<AuthStatus> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
|
|
|
|
if (!res.ok) return { initialized: true, admin_count: 1, registration_allowed: true };
|
|
|
|
|
return (await res.json()) as AuthStatus;
|
|
|
|
|
} catch {
|
|
|
|
|
return { initialized: true, admin_count: 1, registration_allowed: true };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* First-run admin bootstrap. POSTs to `/api/setup`, which creates the admin
|
|
|
|
|
* user and marks the system initialized. Raw `fetch` (NOT apiFetch) so a 401
|
|
|
|
|
* surfaces as a genuine failure instead of triggering the refresh-and-redirect.
|
|
|
|
|
*/
|
|
|
|
|
export async function setupAdmin(email: string, password: string): Promise<void> {
|
|
|
|
|
const res = await fetch('/api/setup', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ username: 'admin', email, password })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
|
|
|
|
throw new Error(e.error || e.message || `setup failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* OIDC code-exchange fallback. When the IdP round-trip lands back on the login
|
|
|
|
|
* page with `?oidc_code=`, exchange it for a session (cookies are set
|
|
|
|
|
* server-side). Raw `fetch` (NOT apiFetch) — a 401 here is a genuine exchange
|
|
|
|
|
* failure, not an expired access token. Returns the user on success, null on
|
|
|
|
|
* any failure so the caller can fall through to the normal login UI.
|
|
|
|
|
*/
|
|
|
|
|
export async function exchangeOidcCode(code: string): Promise<User | null> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/auth/oidc/exchange', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ code })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
const data = (await res.json()) as { user?: User };
|
|
|
|
|
return data.user ?? null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
|
|
|
|
|
* surfaces to the caller instead of tripping the global refresh-and-redirect
|
|
|
|
|
* interceptor — mirrors the login primitive.
|
|
|
|
|
*/
|
|
|
|
|
export async function register(username: string, email: string, password: string): Promise<void> {
|
|
|
|
|
const res = await fetch('/api/auth/register', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ username, email, password, role: 'user' })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
|
|
|
|
throw new Error(e.error || e.message || `register failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type MagicLinkResult = 'sent' | 'unavailable';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Anti-enumeration sign-in by email. Any 2xx resolves to `sent` with a uniform
|
|
|
|
|
* message regardless of whether the email maps to an account. 503 means SMTP
|
|
|
|
|
* isn't configured (`unavailable`) — operators need to see that. Other non-2xx
|
|
|
|
|
* throw so the caller can show a generic error. Raw `fetch` (NOT apiFetch):
|
|
|
|
|
* unauthenticated, must not enter the refresh interceptor.
|
|
|
|
|
*/
|
|
|
|
|
export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
|
|
|
|
|
const res = await fetch('/api/auth/magic-link/send', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: JSON.stringify({ email })
|
|
|
|
|
});
|
|
|
|
|
if (res.status === 503) return 'unavailable';
|
|
|
|
|
if (!res.ok) throw new Error(`magic-link failed: ${res.status}`);
|
|
|
|
|
return 'sent';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
export async function logout(): Promise<void> {
|
|
|
|
|
await apiFetch('/api/auth/logout', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
|
|
|
|
body: '{}'
|
|
|
|
|
});
|
|
|
|
|
}
|