Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
3 changed files with 276 additions and 0 deletions
Showing only changes of commit 869cdc26c8 - Show all commits
+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 {
+84
View File
@@ -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 <img> 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:
* <h1> 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 <img> 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 (
<div style={{ textAlign: 'center' }}>
{/* Phase 17 replaces this div with <img src="..." alt="" /> */}
<div
aria-hidden="true"
style={{
width: 'var(--brand-logo-size, 48px)',
height: 'var(--brand-logo-size, 48px)',
borderRadius: 'var(--brand-logo-border-radius, 50%)',
background: 'var(--brand-logo-bg, var(--color-member-0, #4a90d9))',
color: 'var(--brand-logo-text, #ffffff)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto var(--space-2, 8px)',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
flexShrink: 0,
aspectRatio: '1 / 1',
}}
>
FS
</div>
{/* App name — <h1> so screen readers identify the page (UI-SPEC §Accessibility) */}
<h1
style={{
margin: 0,
marginTop: 'var(--space-2, 8px)',
marginBottom: 'var(--space-1, 4px)',
fontSize: 'var(--text-display-size, 24px)',
fontWeight: 600,
lineHeight: 'var(--text-display-line-height, 1.2)',
color: 'var(--color-text-primary, #111318)',
fontFamily: 'var(--font-family-base)',
}}
>
FamilySync
</h1>
{/* Tagline */}
<p
style={{
margin: 0,
marginBottom: 'var(--space-8, 32px)',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
lineHeight: 'var(--text-body-line-height, 1.5)',
color: 'var(--color-text-secondary, #6b7280)',
fontFamily: 'var(--font-family-base)',
}}
>
Family calendar &amp; lists
</p>
</div>
);
}
+12
View File
@@ -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)
* ───────────────────────────────────────────────────────────────────────── */