feat(19-04): add LoginError, fetchAuthMode/login/logout + auth fetchers, BrandSlot, brand-seam tokens

- Add LoginError class (4 codes: invalid/rate-limit/locked/server) mirroring SessionExpiredError shape
- Add hasLocalCredential to MeUser interface
- Add fetchAuthMode, fetchLocalLogin, fetchLocalLogout (pre-auth endpoints)
- Add fetchChangePassword, fetchCreateMember, fetchAdminResetPassword, fetchLinkOidc
- Create BrandSlot component with Phase-17-ready placeholder (48px circle, FS initials, h1, tagline)
- Add --brand-logo-* CSS custom properties to tokens.css (Phase 17 seam)
This commit is contained in:
Lucas Berger
2026-06-17 17:10:30 -04:00
parent 1cf572a2b9
commit 869cdc26c8
3 changed files with 276 additions and 0 deletions
+180
View File
@@ -57,6 +57,185 @@ function handleAuthResponse(res: Response, label: string): void {
}
}
// ── /api/auth/* (Phase 19 — local auth) ───────────────────────────────────
/**
* Typed error thrown by fetchLocalLogin when the server returns 401/429/423/5xx.
*
* Codes:
* 'invalid' — 401: incorrect username or password
* 'rate-limit' — 429: too many attempts within the rate window
* 'locked' — 423: account locked (persistent lockout)
* 'server' — 5xx or network: transient server error
*
* Object.setPrototypeOf is required so instanceof checks work correctly after
* TypeScript compilation to ES5 / CommonJS (mirrors SessionExpiredError).
*/
export class LoginError extends Error {
readonly name = 'LoginError';
constructor(public readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server') {
super(`Login failed: ${code}`);
Object.setPrototypeOf(this, LoginError.prototype);
}
}
/**
* GET /api/auth/mode — pre-auth endpoint, no session required.
* Returns whether local-auth and/or OIDC are enabled.
* staleTime: 60_000 in App.tsx authModeQuery.
*/
export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }> {
const res = await fetch('/api/auth/mode');
if (!res.ok) {
throw new Error(`fetchAuthMode failed: ${res.status}`);
}
return res.json() as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>;
}
/**
* POST /api/auth/local/login — submit username/password credentials.
*
* Maps status codes to typed LoginError:
* 401 → LoginError('invalid') — incorrect username or password
* 429 → LoginError('rate-limit') — too many attempts
* 423 → LoginError('locked') — account locked
* other non-ok → LoginError('server')
*
* Throws nothing on 200 OK — the local-session cookie is set by the server.
*/
export async function fetchLocalLogin(body: {
username: string;
password: string;
}): Promise<void> {
const res = await fetch('/api/auth/local/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
if (res.status === 401) throw new LoginError('invalid');
if (res.status === 429) throw new LoginError('rate-limit');
if (res.status === 423) throw new LoginError('locked');
if (!res.ok) throw new LoginError('server');
}
/**
* POST /api/auth/local/logout — clear the local-session cookie.
*/
export async function fetchLocalLogout(): Promise<void> {
const res = await fetch('/api/auth/local/logout', {
method: 'POST',
credentials: 'include',
redirect: 'manual',
});
if (!res.ok && res.type !== 'opaqueredirect') {
throw new Error(`fetchLocalLogout failed: ${res.status}`);
}
}
/**
* POST /api/me/password — self-service password change (Phase 19, Surface 12).
* Requires the user's current password and a new password (min 8 chars).
*
* Status codes:
* 401 → wrong current password (throws Error with code 'wrong-current')
* 422 → validation failure (throws Error with code 'validation')
* other non-ok → generic error
*/
export async function fetchChangePassword(body: {
currentPassword: string;
newPassword: string;
}): Promise<void> {
const res = await fetch('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { code?: string };
throw new Error(detail.code ?? 'server');
}
}
/**
* POST /api/admin/members — create a new local member account (Phase 19, Surface 11A).
* Admin-only; server enforces requireAdmin.
*
* Status codes:
* 409 → username already taken
* 422 → validation failure (short password / mismatch)
* other non-ok → generic error
*/
export async function fetchCreateMember(body: {
displayName: string;
username: string;
password: string;
}): Promise<void> {
const res = await fetch('/api/admin/members', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(body),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { code?: string };
throw new Error(detail.code ?? 'server');
}
}
/**
* POST /api/admin/members/:id/reset-password — admin reset of a member's password (Surface 11B).
* Admin-only; server enforces requireAdmin.
*/
export async function fetchAdminResetPassword(
memberId: number,
newPassword: string,
): Promise<void> {
const res = await fetch(`/api/admin/members/${memberId}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify({ newPassword }),
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
throw new Error(`fetchAdminResetPassword failed: ${res.status}`);
}
}
/**
* POST /api/me/link-oidc — initiate the OIDC-link flow for the current local user (Surface 13).
*
* The server returns a redirect URL to begin the OIDC authorization-code flow with a
* state parameter encoding the linkUserId claim. The caller should follow the redirect
* via top-level navigation (window.location.href = result.redirectUrl).
*/
export async function fetchLinkOidc(): Promise<{ redirectUrl: string }> {
const res = await fetch('/api/me/link-oidc', {
method: 'POST',
credentials: 'include',
redirect: 'manual',
});
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) {
throw new Error(`fetchLinkOidc failed: ${res.status}`);
}
return res.json() as Promise<{ redirectUrl: string }>;
}
// ── /api/me ────────────────────────────────────────────────────────────────
export interface MeUser {
@@ -65,6 +244,7 @@ export interface MeUser {
color: string;
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
needsProviderSetup: boolean; // true when no member_credentials row exists for this user
hasLocalCredential: boolean; // true when a local_credentials row exists for this user (Phase 19)
}
export interface MeResponse {