From 869cdc26c867c7e6639accc0fa68991b2592d8ef Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:10:30 -0400 Subject: [PATCH] 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) --- apps/pwa/src/api/client.ts | 180 ++++++++++++++++++++++++++ apps/pwa/src/components/BrandSlot.tsx | 84 ++++++++++++ apps/pwa/src/styles/tokens.css | 12 ++ 3 files changed, 276 insertions(+) create mode 100644 apps/pwa/src/components/BrandSlot.tsx diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 6bfd1fd..329f422 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/apps/pwa/src/components/BrandSlot.tsx b/apps/pwa/src/components/BrandSlot.tsx new file mode 100644 index 0000000..5ee2090 --- /dev/null +++ b/apps/pwa/src/components/BrandSlot.tsx @@ -0,0 +1,84 @@ +/** + * BrandSlot — Phase 17 seam component for the login page brand area. + * + * Phase 19 ships a minimal shippable placeholder: a 48px circle with "FS" + * initials, the app name "FamilySync", and the tagline "Family calendar & lists". + * + * Phase 17 replaces the internals of this component (swap the placeholder div for + * an with a real logo) without touching LoginPage's layout. This isolates + * the branding seam — see 19-UI-SPEC.md §Brand Slot section. + * + * CSS custom properties used (all set in tokens.css with placeholder defaults; + * Phase 17 overrides these values): + * --brand-logo-bg — logo circle background (default: var(--color-member-0)) + * --brand-logo-text — initials color (default: #ffffff) + * --brand-logo-size — circle diameter (default: 48px) + * --brand-logo-border-radius — circle shape (default: 50%) + * + * Accessibility: + *

contains the app name — screen readers read "FamilySync" as the page title. + * The logo circle is aria-hidden (the text is the accessible label). + * No today → no broken image ref → no layout shift when Phase 17 replaces it. + * + * Security: all copy is plain-text JSX children — no dangerouslySetInnerHTML (T-05-24). + */ + +export function BrandSlot() { + return ( +
+ {/* Phase 17 replaces this div with */} + + + {/* App name —

so screen readers identify the page (UI-SPEC §Accessibility) */} +

+ FamilySync +

+ + {/* Tagline */} +

+ Family calendar & lists +

+
+ ); +} diff --git a/apps/pwa/src/styles/tokens.css b/apps/pwa/src/styles/tokens.css index 578ce00..88fa3bf 100644 --- a/apps/pwa/src/styles/tokens.css +++ b/apps/pwa/src/styles/tokens.css @@ -88,6 +88,18 @@ --text-display-weight: 600; --text-display-line-height: 1.2; + /* ───────────────────────────────────────────────────────────────────────── + * BRAND SLOT — Phase 17 seam tokens + * Phase 19 sets placeholder defaults; Phase 17 overrides these values only — + * never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot). + * ───────────────────────────────────────────────────────────────────────── */ + + --brand-logo-bg: var(--color-member-0); /* placeholder circle background */ + --brand-logo-text: #ffffff; /* placeholder initials color */ + --brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */ + --brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */ + --brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */ + /* ───────────────────────────────────────────────────────────────────────── * BREAKPOINTS (reference; use in @media queries) * ───────────────────────────────────────────────────────────────────────── */