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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user