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 ────────────────────────────────────────────────────────────────
|
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MeUser {
|
export interface MeUser {
|
||||||
@@ -65,6 +244,7 @@ export interface MeUser {
|
|||||||
color: string;
|
color: string;
|
||||||
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
|
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
|
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 {
|
export interface MeResponse {
|
||||||
|
|||||||
@@ -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 & lists
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,6 +88,18 @@
|
|||||||
--text-display-weight: 600;
|
--text-display-weight: 600;
|
||||||
--text-display-line-height: 1.2;
|
--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)
|
* BREAKPOINTS (reference; use in @media queries)
|
||||||
* ───────────────────────────────────────────────────────────────────────── */
|
* ───────────────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user