5 Commits
Author SHA1 Message Date
Lucas BergerandClaude Opus 4.8 9ef7eaada8 docs(state): record phase 19 UI-SPEC approval; ignore local claude settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:21:25 -04:00
Lucas BergerandClaude Sonnet 4.6 4dd6068dcc docs(19): mark UI-SPEC approved after checker verification
All 6 design dimensions PASS plus Phase 17 brand-slot readiness contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:19:45 -04:00
Lucas BergerandClaude Sonnet 4.6 71bf21634c docs(19): UI design contract for local auth login screen and admin additions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:18:04 -04:00
Lucas Berger 45fca0ed6b docs(state): record phase 19 context session 2026-06-16 21:04:57 -04:00
Lucas Berger 64fa4653da docs(19): capture phase context 2026-06-16 21:04:52 -04:00
5 changed files with 993 additions and 5 deletions
+3
View File
@@ -17,6 +17,9 @@ dist/
*.swp
*.swo
# Claude Code local (per-machine) settings — never tracked
.claude/settings.local.json
# OS
.DS_Store
Thumbs.db
+5 -5
View File
@@ -3,8 +3,8 @@ gsd_state_version: 1.0
milestone: v1.1
milestone_name: Operability & Polish
status: "Phase 12 shipped — PR #22"
stopped_at: Completed 12-06-PLAN.md (UAT gaps 2+3 closed)
last_updated: "2026-06-16T20:24:41.714Z"
stopped_at: Phase 19 UI-SPEC approved
last_updated: "2026-06-17T01:20:23.192Z"
last_activity: 2026-06-16
progress:
total_phases: 24
@@ -268,9 +268,9 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-16T01:15:09.630Z
Stopped at: Completed 12-06-PLAN.md (UAT gaps 2+3 closed)
Resume file: None
Last session: 2026-06-17T01:20:23.179Z
Stopped at: Phase 19 UI-SPEC approved
Resume file: .planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md
## Operator Next Steps
@@ -0,0 +1,137 @@
# Phase 19: Local Auth (No-OIDC Mode) - Context
**Gathered:** 2026-06-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Let an operator run FamilySync entirely on **local DB username/password accounts with no OIDC/Authelia required**, while keeping OIDC available as an opt-in, generic (RFC-compliant, not Authelia-specific) provider that can be wired in later from the admin UI. Builds directly on the Phase-12 pre-OIDC local-user foundation (nullable `users.oidc_iss`/`oidc_sub`, the `claimed` marker, and the first-login-claims merge in `upsertUser`).
**In scope:** local credential storage (scrypt) + local login flow; a new local login UI in the PWA; a stateless local-session cookie + middleware; coexistence with the existing OIDC middleware; admin-managed local account creation + password set/change/reset; per-user OIDC-link (replacing local for that user); de-Authelia-izing OIDC config/copy to a generic OIDC provider; a lockout/break-glass recovery mechanism; reworking dev-bypass + the Phase 7/8 Playwright harness to cover the new login UI.
**Out of scope:** a full pluggable multi-auth-provider framework (LDAP, magic-link, multiple OIDC) — that is the auth-layer counterpart of backlog 999.1, a future phase. Email-based password reset (email is out of project scope). BYO-CalDAV provider abstraction (999.1, separate).
</domain>
<decisions>
## Implementation Decisions
### Mode & Coexistence
- **D-01:** Local auth is the **default and always available**. OIDC is **opt-in/additive**, never a replacement for the local path at the system level.
- **D-02:** OIDC is configured from the **admin UI** (extends the Phase-12 config that already lands in `app_config`: `oidc_issuer`, `oidc_client_id`, `app_external_url`). When OIDC is configured, **both methods are offered and the user chooses at login** (local username/password OR "Login with OIDC").
- **D-03:** This must **not break the existing live OIDC deployment**. The two current household members already authenticate via Authelia (`oidc_iss`/`oidc_sub` set, `claimed=true`); they continue as OIDC users. Local auth is layered on additively.
- **D-04:** A **new local login UI (username + password) must be built in the PWA** — none exists today. The PWA currently boots straight into the authed app (OIDC redirect) or via dev-bypass; there is no login form.
### Session Issuance
- **D-05:** Local logins are backed by a **stateless signed httpOnly JWT cookie** carrying `userId`, validated by a **new local-auth middleware that sets `c.get('user')`** the same way `auth/devBypass.ts` does — so every downstream route resolves the user unchanged. **No DB sessions table** (consistent with the app's existing storage-less-JWT approach; right for household scale). Tradeoff accepted: a password change cannot retroactively invalidate other live sessions; logout = clear cookie.
- **D-06 (BYO-Auth principle):** Local auth is first-class; OIDC is treated as a **generic RFC-compliant provider, not Authelia-hardcoded**. `@hono/oidc-auth` is already provider-agnostic — work is to de-Authelia-ize config keys and user-facing copy and treat issuer/client as generic OIDC config. Mirrors the planned BYO-CalDAV provider abstraction (999.1).
- **D-07 (BYO-Auth scope):** Ship **local + one generic OIDC** with a **clean internal seam** for future methods. **No plugin/registry framework** in this phase.
### Password Hashing & Storage
- **D-08:** Hash local passwords with **`node:crypto` scrypt** — zero new dependency, no native node-gyp build in the Docker image (honors the stack's deliberate no-native-dep stance, the same reason Drizzle was chosen over Prisma). Encode **algorithm + params + salt alongside the hash** so parameters can evolve. (argon2id/bcrypt native addons explicitly rejected.)
- **D-09:** Store local credentials in a **new `local_credentials` table**`user_id` (FK to `users`, UNIQUE), `username` (UNIQUE), `password_hash` (encoded), `createdAt`/`updatedAt` — mirroring the `member_credentials` pattern. Keeps the `users` row identity-method-agnostic. **Auth methods are a per-user property**: a user has local login iff a `local_credentials` row exists, and OIDC login iff an `oidc_iss+oidc_sub` binding exists. Drizzle **generate+migrate, never push** (additive migration on populated MariaDB — same rule as Phases 10/12).
### Accounts & OIDC-Link
- **D-10:** **Admin creates members** + sets an initial password; the member changes it later. **No open self-signup** (wrong trust model for a private household app exposed via Pangolin).
- **D-11:** Password lifecycle = **self-change (current + new) + admin-reset** from the admin UI. **No email reset** (email out of project scope). Reuses the admin surface that already rotates Fastmail app passwords.
- **D-12:** **OIDC link replaces local at the per-user level**: when a local user links an OIDC identity (explicit action while authenticated as that user — never an email match, per Phase-12 D-10), **delete that user's `local_credentials` row** → they become OIDC-only. OIDC-only users never receive a local credential. The returned `iss+sub` must not already belong to another user.
- **D-13 (break-glass):** Lockout recovery does **not** need to be a permanent local user account (avoids a member-vs-operator capability split — explicitly rejected). Instead, recovery is a **CLI/console command and/or env override** (e.g. create/reset a local admin, or disable/force-off OIDC), run on the host/container. **No new role/capability model**; reuse today's single `users.is_admin`. Exact form → researcher (see Open Questions).
### Testing & Dev-Bypass
- **D-14:** The new login UI requires touching existing API/unit tests and the **Phase 7/8 Playwright harness** (which today reaches the authed PWA purely via `DEV_AUTH_BYPASS`, skipping any login). Both the already-authed fast path and the **real login form** must remain testable.
- **D-15 (hard constraint):** Any seeded test login / reworked dev-bypass mechanism **stays dev-only and never ships in the Docker/prod image**. It is bound by the existing Phase-16 image-hygiene gates: the IMG-01 boot guard (`assertNotDevBypassInProduction`), `.dockerignore` (IMG-02), and the publish-time hygiene assertion (IMG-03). New dev-seed-login artifacts must be covered by those same gates.
### Claude's Discretion (decided in-discussion)
- Session backing mechanism (chose stateless signed JWT cookie — D-05).
- Credential storage location (chose separate `local_credentials` table — D-09).
These were "you decide" responses; rationale captured above. Researcher/planner may refine implementation detail but should not reverse the locked choice without cause.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Auth foundation this phase extends
- `apps/api/src/auth/user.ts``upsertUser` (identity = `oidc_iss+oidc_sub`, never email; first-login-claims of the single unclaimed row; first-login-wins `is_admin` bootstrap; `claimed` semantics). The local-account + OIDC-link model generalizes this.
- `apps/api/src/auth/middleware.ts` — OIDC middleware wiring + `oidcConfigFallbackMiddleware` (env-OR-`app_config` fallback for `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`app_external_url`). The generic-OIDC config path lives here.
- `apps/api/src/auth/devBypass.ts``devAuthBypass()` + `DEV_USER`; the `c.set('user', …)` pattern the new local-auth middleware mirrors. Subject of the dev-bypass rework (D-14/D-15).
- `apps/api/src/auth/persistSessionCookie.ts` — session-cookie persistence helper (referenced by the session model).
- `apps/api/src/index.ts` — middleware mount order (`/api/setup` pre-auth → `devAuthBypass``oidcConfigFallback``oidcAuthMiddleware``persistSessionCookie`); `devBypassActive` computed once at boot; `assertNotDevBypassInProduction()` boot guard. Local-login routes + middleware slot in here.
- `apps/api/src/routes/me.ts``resolveUserId` (dev-bypass `c.get('user')` first, else `getAuth`) + `needsProviderSetup`/`isAdmin` exposure. The user-resolution seam for all routes.
- `apps/api/src/db/schema.ts``users` (nullable `oidc_iss`/`oidc_sub`, `claimed`, `is_admin`, `uniq_oidc_identity`), `member_credentials` (pattern to mirror for `local_credentials`), `app_config` (k/v config; PROHIBITION list for secrets-in-DB).
- `apps/api/src/routes/admin.ts` + `apps/api/src/lib/requireAdmin.ts` — admin route surface + role guard the account-management UI and OIDC config UI extend.
- `apps/api/src/routes/setup.ts` + `apps/api/src/lib/setupGuard.ts` — Phase-12 pre-auth wizard + 423 lock; the first-local-admin bootstrap replaces the current unclaimed-user provisioning.
### Image hygiene / dev-prod boundary (constrains D-15)
- `apps/api/src/lib/bootGuards.ts``assertNotDevBypassInProduction` (IMG-01).
- `.dockerignore` (repo root) — IMG-02 dev-artifact exclusion.
- `.gitea/workflows/publish.yml` — IMG-03 publish-time image-hygiene + boot-smoke assertions.
### Test harness this phase must update
- `apps/pwa/playwright.config.ts` + `apps/pwa/e2e/` (global-setup deterministic mysql2 seed, `layout.spec.ts`, `calendar.spec.ts`, `lists.spec.ts`) — Phase 7 harness; auth reached via `DEV_AUTH_BYPASS`.
- `.gitea/workflows/ci.yml` — CI `harness` job (brings up dev stack with `DEV_AUTH_BYPASS=true`).
### Provenance / prior decisions
- `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas — origin of this phase (full local-auth/no-OIDC mode deferred from Phase 12; D-07 local-user groundwork is the deliberate foundation).
- `.planning/ROADMAP.md` §"Phase 19" — goal, dependency on Phase 12, and the four seed open questions.
- `.planning/PROJECT.md` §Constraints / §Auth — Authelia-OIDC constraint context; MariaDB-only; no-native-dep stance.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `member_credentials` table shape + `validateEncryptAndStoreCredential` flow (`broker/credentialSync.ts`) — direct template for the `local_credentials` table and an admin-managed create/reset write path.
- `devAuthBypass()`'s `c.set('user', …)` pattern — the new local-auth middleware reuses it so downstream routes (`resolveUserId` in every router) need no change.
- `oidcConfigFallbackMiddleware` (env-OR-`app_config`) — the established pattern for admin-UI-written OIDC config taking effect.
- Phase-12 `upsertUser` claim/link machinery — the OIDC-link flow (D-12) is a generalization (bind `iss+sub` to an already-authenticated local user, then drop their local credential).
- `assertNotDevBypassInProduction` + `.dockerignore` + `publish.yml` hygiene assertions — the enforcement surface for D-15.
### Established Patterns
- **Identity = `oidc_iss+oidc_sub`, never email** (Phase-12 D-10) — local accounts are a separate per-user credential, and OIDC-link must be explicit (no email matching).
- **Drizzle generate+migrate, never push** — additive migration on populated MariaDB (Phases 10/12 precedent); applies to the new `local_credentials` table.
- **`is_admin` is the server boundary; client `isAdmin` is UX-only** — local-auth admin gating reuses `requireAdmin`.
- **Secrets stay in env, never in `app_config`/DB** (Phase-12 PROHIBITION) — the local-session signing secret and scrypt config live in env, not the DB.
- **Storage-less JWT session cookie** (CLAUDE.md, `@hono/oidc-auth`) — the local-session cookie follows the same stateless philosophy (D-05).
### Integration Points
- New local-login routes + local-auth middleware mount in `index.ts` alongside (and ordered against) `devAuthBypass`/`oidcAuthMiddleware`; the OIDC guard must not 302-redirect local-mode requests.
- The PWA gate (App.tsx setup/login routing) gains a login screen and a login-vs-OIDC chooser; `/api/me` / a new auth-mode endpoint tells the PWA which methods to offer.
- Setup wizard bootstrap shifts from "provision one unclaimed user" to "create the first local admin (username+password)".
</code_context>
<specifics>
## Specific Ideas
- "Bring Your Own Auth" framing (user's words) — explicitly do not pigeon-hole into Authelia; OIDC is one generic provider, parallel to the intended "Bring Your Own CalDAV provider" direction (999.1).
- User leans toward **"replace dev-bypass with seeded auto-login"** for the harness, but defers the final call to the researcher.
- User prefers the **break-glass to be a CLI/env override rather than a user account**, to avoid added user/capability complexity.
</specifics>
<deferred>
## Deferred Ideas
- **Full pluggable auth-provider framework** (registry/plugin for LDAP, magic-link, multiple simultaneous OIDC providers) — auth-layer counterpart of backlog 999.1; its own future phase/milestone. Phase 19 builds only a clean internal seam.
- **Member-vs-operator capability/role split** — considered for the break-glass account, explicitly rejected in favor of a CLI/env recovery mechanism + the existing single `is_admin` flag.
- **Email-based password reset** — out of project scope (no email features).
## Open Questions for Research
- **Dev-bypass rework (decide among 3):** (a) keep bypass + seed a real test login for login-specific specs; (b) replace bypass with seeded auto-login through the real local flow (user's lean); (c) bypass auto-issues a real local-session cookie. Must satisfy D-14 + the D-15 dev-only/no-prod-image constraint.
- **Break-glass recovery form:** CLI/console command vs env override (or both) for create/reset-local-admin and/or disable-OIDC; how it interacts with the boot-time mode/middleware selection.
- **OIDC-only user provisioning:** how an OIDC-only user is first created given D-10 forbids email-matching unclaimed rows — just-in-time on first OIDC login vs admin pre-creation + claim (the Phase-12 single-unclaimed-row claim can't disambiguate multiple pre-created placeholders).
- **Login-vs-OIDC mode signalling to the PWA:** reuse/extend `/api/me` or `/api/setup/status`, or a new pre-auth `/api/auth/mode` endpoint, so the login page knows which methods to render.
- **Local-login hardening:** rate-limiting / lockout / timing-safe compare on the local login endpoint (household scale, but Pangolin-exposed).
</deferred>
---
*Phase: 19-local-auth-no-oidc-mode*
*Context gathered: 2026-06-16*
@@ -0,0 +1,161 @@
# Phase 19: Local Auth (No-OIDC Mode) - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-16
**Phase:** 19-local-auth-no-oidc-mode
**Areas discussed:** Mode & coexistence, Session issuance, Password hashing & storage, Accounts & OIDC-link, Testing & dev-bypass
---
## Mode & Coexistence
### Q1 — How should the app decide between local-auth and OIDC?
| Option | Description | Selected |
|--------|-------------|----------|
| app_config flag (runtime) | `auth_mode` row in app_config, set by wizard; no restart | |
| Deploy-time env switch | `AUTH_MODE` env read at boot | |
| Both always live | Local form + OIDC button always shown | |
**User's choice:** Free-text — "Default to local and add the ability to wire OIDC in later if wanted."
**Notes:** Local is the always-available default; OIDC is additive/opt-in.
### Q2 — How does the app know OIDC is wired in, and what happens to local login?
| Option | Description | Selected |
|--------|-------------|----------|
| Auto-detect, local stays live | OIDC on when config present; local always available | (partial) |
| Auto-detect, OIDC takes over | Local disabled once OIDC present | |
| Explicit app_config toggle | Separate `auth_mode` controlled from admin UI | (partial) |
**User's choice:** Free-text — OIDC config is set/stored in a later step, so wire it into the **admin UI**; give users the choice of which to use at login; **no local login UI exists today** so it must be built.
**Notes:** Blend — admin-UI-configured OIDC, both methods offered at login, user chooses.
---
## Session Issuance
### Q1 — What backs a local login session?
| Option | Description | Selected |
|--------|-------------|----------|
| Stateless signed JWT cookie | userId in signed httpOnly cookie; no DB table | ✓ (Claude) |
| Server-side session table | sessions table for true revocation | |
| You decide | — | ✓ |
**User's choice:** "You decide" + "do not pigeon-hole the user into Authelia — Bring Your Own Auth and Bring Your Own CalDAV provider."
**Notes:** Claude chose stateless signed JWT cookie. User added the BYO-Auth architectural principle (generic OIDC, not Authelia-locked).
### Q2 — How far should the BYO-Auth abstraction go in Phase 19?
| Option | Description | Selected |
|--------|-------------|----------|
| Local + generic OIDC | Two concrete methods, clean seam, no framework | ✓ |
| Full pluggable framework | Provider registry/plugin (LDAP, magic-link, multi-OIDC) | |
| Local only for now | Leave Authelia OIDC as-is, defer generic OIDC | |
**User's choice:** Local + generic OIDC (recommended).
**Notes:** Clean internal seam now; full framework deferred.
---
## Password Hashing & Storage
### Q1 — Which password hashing approach?
| Option | Description | Selected |
|--------|-------------|----------|
| scrypt via node:crypto | Stdlib, zero-dep, no native build | ✓ |
| argon2id (native dep) | OWASP top pick, needs native addon | |
| bcrypt (bcryptjs) | Pure JS, older KDF | |
**User's choice:** scrypt via node:crypto (recommended).
**Notes:** Honors the stack's no-native-dep stance.
### Q2 — Where to store username + hash?
| Option | Description | Selected |
|--------|-------------|----------|
| Separate local_credentials table | Mirrors member_credentials; user-agnostic users row | ✓ (Claude) |
| Columns on users | Add username + password_hash to users | |
| You decide | — | ✓ |
**User's choice:** "You decide."
**Notes:** Claude chose a separate `local_credentials` table — best fits the BYO-Auth per-user-method seam (one row can hold both a local credential and an OIDC binding).
---
## Accounts & OIDC-Link
### Q1 — How are local accounts created?
| Option | Description | Selected |
|--------|-------------|----------|
| Admin creates members | Wizard creates first admin; admin creates rest | ✓ |
| Admin creates + invite link | One-time set-password link | |
| Open self-signup | Anyone can register | |
**User's choice:** Admin creates members.
### Q2 — Password change/reset?
| Option | Description | Selected |
|--------|-------------|----------|
| Self-change + admin reset | Member self-change; admin resets lockouts | ✓ |
| Self-change only | No admin reset | |
| Admin reset only | No self-change | |
**User's choice:** Self-change + admin reset.
**Notes:** No email reset (email out of scope).
### Q3 — After OIDC-link, what methods stay valid? (reformulated after clarification)
| Option | Description | Selected |
|--------|-------------|----------|
| Both stay valid | Row holds local + OIDC; either logs in | |
| OIDC primary, local fallback | Same data model, UI emphasis on OIDC | |
| OIDC replaces local | Linking removes local credential | ✓ (per user) |
**User's choice:** Initially requested clarification; then chose **OIDC replaces local per user** — there can/should be OIDC-only users with no local creds. Raised the need for a break-glass path.
**Notes:** Auth methods are per-user (presence of local_credentials row and/or OIDC binding). Break-glass need surfaced here.
### Q4 — Break-glass capability model?
| Option | Description | Selected |
|--------|-------------|----------|
| Protected local admin (no new role model) | Initial admin, un-removable local cred | |
| Operator-only account (member/operator split) | Strip member capability from break-glass | |
| Let researcher scope it | Lock the requirement, defer the how | ✓ (twist) |
**User's choice:** Let researcher scope it — **with a twist: break-glass can be a CLI/console command or env override instead of a user**, removing the added-user/capability complexity.
**Notes:** No new role/capability model; reuse `is_admin`. Recovery mechanism (not account) to be scoped by researcher.
---
## Testing & Dev-Bypass (added mid-discussion at user's request)
### Q1 — How should DEV_AUTH_BYPASS evolve?
| Option | Description | Selected |
|--------|-------------|----------|
| Bypass stays + seed a real test login | Fast bypass for most specs; real form for login specs | |
| Replace bypass with seeded auto-login | Harness logs in via real local flow | (user's lean) |
| Bypass auto-issues a real local session | Bypass logs in seeded user, skips form | |
**User's choice:** Defer final determination to the **research agent**; user **leans toward "replace bypass with seeded auto-login."**
**Notes:** Hard constraint — the seeded test login / dev-bypass **stays dev-only and never ships in the Docker/prod image** (Phase 16 IMG-01/02/03 gates apply).
---
## Claude's Discretion
- Local session backing → stateless signed JWT cookie (D-05).
- Credential storage location → separate `local_credentials` table (D-09).
## Deferred Ideas
- Full pluggable auth-provider framework (registry/plugin; LDAP, magic-link, multi-OIDC) — future phase, counterpart of 999.1.
- Member-vs-operator capability/role split — rejected in favor of CLI/env break-glass recovery.
- Email-based password reset — out of project scope.
@@ -0,0 +1,687 @@
---
phase: 19
slug: local-auth-no-oidc-mode
status: approved
shadcn_initialized: false
preset: none
created: 2026-06-16
approved: 2026-06-16
---
# Phase 19 — UI Design Contract: Local Auth (No-OIDC Mode)
> Visual and interaction contract for the local login screen, login-method chooser,
> and admin-surface additions for local account management.
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
---
## Context & Audience
This phase introduces the **first real login UI** in the FamilySync PWA. Today the PWA boots
straight into the authed app (OIDC redirect) or via dev-bypass — there is no login form. Phase 19
builds:
1. A **local login screen** (username + password) — full-viewport, pre-auth, the first surface an
unauthenticated user sees. This is the highest-value branding surface in the app.
2. A **login-method chooser** rendered when OIDC is also configured (D-02) — local form OR
"Login with OIDC" (generic, never says "Authelia" — D-06).
3. Admin-surface additions (in-app shell `/admin` route, extending Phase 10): local member
creation + initial password; self password-change; admin password-reset; per-user
"Link OIDC identity" action.
The login screen is **end-user-facing**, not operator-facing. The non-technical Apple household
member is the primary user — UX must be slick and low-friction (CLAUDE.md hard constraint).
The login screen is a **standalone full-page route**, most closely analogous to the Phase 12
setup wizard (`/setup`). It renders none of the AppNav / BottomTabBar / SetupBanner chrome.
All design tokens are inherited from `apps/pwa/src/styles/tokens.css`. No new tokens are
introduced.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none (existing CSS custom properties) |
| Preset | not applicable |
| Component library | none (hand-rolled inline `React.CSSProperties`, project convention) |
| Icon library | lucide-react (already installed — `Lock`, `User`, `Eye`, `EyeOff`, `Loader2`, `AlertCircle`, `LogIn`, `ShieldCheck`) |
| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif (var(--font-family-base)) |
Source: `apps/pwa/src/styles/tokens.css` — pre-populated from existing codebase scan.
Pattern baseline: `apps/pwa/src/routes/SetupPage.tsx` (full-viewport standalone page),
`apps/pwa/src/routes/AdminPage.tsx` (admin-surface additions).
---
## Spacing Scale
Uses the existing 4px-based scale. No new tokens.
| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| --space-1 | 4px | Icon gaps, label-to-input gap, helper-text margin-top |
| --space-2 | 8px | Compact element spacing, password show/hide button gap, form field gap within a group |
| --space-3 | 12px | Input padding (vertical), row gaps |
| --space-4 | 16px | Between form fields, button horizontal padding, card horizontal padding |
| --space-6 | 24px | Card padding, section gap, brand slot bottom margin |
| --space-8 | 32px | Between the brand slot and the login card, between major sections |
| --space-12 | 48px | Page top/bottom padding (matches SetupPage pattern) |
Exceptions:
- Login card max-width: 400px (narrower than wizard 540px; a two-field login needs less width).
- All interactive elements: `minHeight: 44px; minWidth: 44px` (WCAG 2.5.5 Touch Target).
- Password show/hide toggle: 44px tap target embedded inside the input row (right-side icon button).
- Brand logo slot: reserved 48px height (aspect-ratio box 1:1); see Brand Slot section.
---
## Typography
All values from `tokens.css`. No new sizes or weights.
| Role | Size | Weight | Line Height | Variable |
|------|------|--------|-------------|----------|
| Body | 15px | 400 | 1.5 | var(--text-body-size) / var(--text-body-weight) / var(--text-body-line-height) |
| Label | 13px | 400 | 1.4 | var(--text-label-size) / var(--text-label-weight) / var(--text-label-line-height) |
| Heading | 18px | 600 | 1.25 | var(--text-heading-size) / var(--text-heading-weight) / var(--text-heading-line-height) |
| Display | 24px | 600 | 1.2 | var(--text-display-size) / var(--text-display-weight) / var(--text-display-line-height) |
Usage in this phase:
- App name "FamilySync" in brand slot: Display (24px/600/1.2) — `var(--color-text-primary)`
- App tagline "Family calendar & lists" in brand slot: Body (15px/400/1.5) — `var(--color-text-secondary)`
- Login card heading ("Sign in"): Heading (18px/600/1.25) — `var(--color-text-primary)`
- Field labels, helper text, divider label ("or"): Label (13px/400/1.4)
- Field labels use weight 600, helper text uses weight 400
- Section labels in admin additions ("LOCAL ACCOUNTS", "OIDC LINK"):
13px/600/uppercase/0.06em letter-spacing (AdminPage `sectionLabelStyle` pattern)
- Error messages: Body (15px/400/1.5) — `var(--color-destructive)`
- Primary CTA label: Label (13px/600)
---
## Color
All values from `tokens.css`. No new hex values.
| Role | Value | Variable | Usage |
|------|-------|----------|-------|
| Dominant (60%) | #ffffff | var(--color-surface) | Page background, card background, input background |
| Secondary (30%) | #f7f7f8 | var(--color-surface-dim) | Divider area between form methods, info banners, rate-limit notice background |
| Accent (10%) | #4a90d9 | var(--color-member-0) | Primary CTA button ("Sign in"), spinner, focus ring, "Login with OIDC" button border |
| Destructive | #dc2626 | var(--color-destructive) | Error message text, error-state input border, lockout notice, rate-limit warning |
Accent reserved for:
- "Sign in" button (filled background)
- "Login with OIDC" button (outlined, `1px solid var(--color-member-0)`, accent text)
- `Loader2` spinner during login submit
- Focus ring on all inputs and buttons (`var(--color-focus-ring)`, 2px outline, 2px offset)
- Text links (e.g., "Forgot password? Ask your admin.")
Additional semantic colors (not new — already in tokens.css):
- `var(--color-border)` #e2e4e9 — card border, input border (default), divider line
- `var(--color-border-subtle)` #eceef2 — section dividers in admin additions
- `var(--color-text-primary)` #111318 — headings, field values, app name
- `var(--color-text-secondary)` #6b7280 — descriptions, helper text, tagline, divider label
- `var(--color-text-muted)` #9ca3af — placeholder text, inactive admin rows
- `var(--color-overlay)` rgba(0,0,0,0.32) — modal backdrop for confirmation dialogs
---
## Brand Slot — Phase 17 Readiness
The login screen is the **highest-value branding surface** in the app — full-viewport,
unauthenticated, the first thing any user sees. A reserved brand slot sits above the login
card and is designed as a **theming/asset seam**: Phase 19 ships a minimal shippable
placeholder; Phase 17 drops in real assets without restructuring the layout.
### Brand slot structure (Phase 19 ships this)
```
[brand-slot]
[--brand-logo placeholder] — 48×48px box, aspect-ratio 1/1, reserved intrinsic dimensions
Placeholder: a 48px circle, background var(--color-member-0),
initials "FS" in white Display (24px/600).
No broken image ref. No layout shift when replaced.
[--brand-app-name] — "FamilySync" text (Display 24px/600, var(--color-text-primary))
Rendered from a CSS custom property / named slot; not hardcoded.
[--brand-tagline] — "Family calendar & lists" (Body 15px/400, var(--color-text-secondary))
```
Layout:
- Centered column, `textAlign: center`
- Logo mark: `width: 48px; height: 48px; borderRadius: 50%; margin: 0 auto var(--space-2)`
- App name: `marginTop: var(--space-2); marginBottom: var(--space-1)`
- Tagline: `marginBottom: var(--space-8)` (32px gap before the login card)
### Asset seam tokens
Define in `tokens.css` (Phase 19 sets placeholder defaults; Phase 17 overrides):
```css
:root {
/* Phase 17 replaces these values — never the component structure */
--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'; /* not used as CSS content — drives doc only */
}
```
The logo slot renders via a React component `<BrandSlot />` in the login page — not inline JSX.
This isolates the seam: Phase 17 replaces `<BrandSlot>` internals (swap placeholder div for
`<img src="...">`) without touching `<LoginPage>` layout.
### Phase 17 readiness subsection
**Phase 17 contract — what Phase 17 must honor:**
| Slot | Asset Phase 17 provides | Constraints Phase 17 must respect |
|------|-------------------------|-----------------------------------|
| Logo mark | SVG or PNG, favicon-derived | Must fit in 48×48px box at 1x; provide 2x/3x for retina. `alt=""` (decorative — app name already in text) |
| App name text | Same string "FamilySync" or updated display name | Rendered as text, not image — screen readers read it |
| Tagline | Optional; may be removed | If removed, set `--brand-tagline-display: none` — no layout reflow |
| Background hero | Optional — if added, must go behind the entire page, not just the brand slot | `var(--brand-bg): none` default; Phase 17 sets to a CSS gradient or subtle image |
| Aspect-ratio box | Phase 17 MUST keep the 48px height reserve | Prevents layout shift; use `aspect-ratio: 1/1; width: var(--brand-logo-size)` |
Phase 17 asset swap is: update `<BrandSlot>` internals (image src) + set CSS custom property
values. No changes to `<LoginPage>` layout, spacing, or card structure are permitted by this
contract.
---
## Surface Architecture
### Surface 1 — Login Page Shell (`/login`)
A standalone full-page route. No AppNav, no BottomTabBar, no SetupBanner, no
PermissionDeniedBanner at any breakpoint.
- Background: `var(--color-surface)` (#ffffff)
- Layout: `minHeight: 100dvh; display: flex; flexDirection: column; alignItems: center; justifyContent: flex-start`
- Content column: `maxWidth: 400px; width: 100%; margin: 0 auto; padding: var(--space-12) var(--space-6)`
Routing gate:
1. On app load, `GET /api/auth/mode` (pre-auth endpoint — no session required) returns
`{ localEnabled: true, oidcEnabled: boolean }`.
2. If the user already has a valid session (local JWT cookie or OIDC session), they are
redirected to `/calendar` before the login page renders.
3. The `/login` route renders the `<LoginPage>` (full-viewport, no shell).
4. After successful login, navigate to `/` (which redirects to `/calendar`).
### Surface 2 — Brand Slot
Sits at the top of the content column, above the login card. Detailed in "Brand Slot" section.
Not inside the login card — floats above it in the flow.
### Surface 3 — Login Card
The primary login interaction area.
- Background: `var(--color-surface)` (#ffffff)
- Border: `1px solid var(--color-border)` (#e2e4e9)
- Border-radius: 8px
- Padding: `var(--space-6)` (24px) all sides
- Box-shadow: `0 1px 4px rgba(0,0,0,0.06)` (matches SetupPage cardStyle)
- Card heading "Sign in": Heading (18px/600/1.25), `var(--color-text-primary)`,
`marginBottom: var(--space-6)` (24px)
### Surface 4 — Username Field
- Label: "Username" — 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px)
- Input: `type="text"`, `autoComplete="username"`, `id="login-username"`
- Style: full-width, `padding: var(--space-3) var(--space-4)`, `border: 1px solid var(--color-border)`,
`borderRadius: var(--space-1)`, 15px/400, `var(--color-text-primary)`, `background: var(--color-surface)`
- Error state border: `1px solid var(--color-destructive)`
- `aria-describedby="login-error"` when error state is active
- `spellCheck={false}`, `autoCapitalize="none"`, `autoCorrect="off"`
### Surface 5 — Password Field with Show/Hide Toggle
- Label: "Password" — 13px/600, `var(--color-text-primary)`, `marginBottom: var(--space-1)` (4px)
- Input wrapper: `position: relative`
- Input: `type="password"` (toggled to `"text"` by show/hide button), `autoComplete="current-password"`,
`id="login-password"`, `paddingRight: 44px` (space for toggle)
- Error state border: `1px solid var(--color-destructive)`
- Show/hide toggle button: `position: absolute; right: 0; top: 0; height: 100%; minWidth: 44px;
background: none; border: none; cursor: pointer; color: var(--color-text-muted)` —
renders lucide `Eye` (show) or `EyeOff` (hide), 16px, `aria-label="Show password"` /
`"Hide password"`, `aria-pressed` reflects current state
- Field container `marginBottom: var(--space-4)` (16px)
### Surface 6 — Form Error / Lockout Banner
Shown below the password field, above the submit button. Uses `role="status"` + `aria-live="polite"`.
**Error states in order of severity:**
1. **Invalid credentials** (incorrect username or password):
- Icon: `AlertCircle` (16px, `var(--color-destructive)`) inline
- Copy: "Incorrect username or password." — Body (15px/400), `var(--color-destructive)`
- Both fields remain editable; no field is specifically blamed (timing-safe: do not indicate
which field is wrong)
- Input borders: both switch to `var(--color-destructive)`
2. **Rate limit** (too many attempts, not yet locked):
- Background: `var(--color-surface-dim)` pill/banner, `border-radius: var(--space-1)`,
`padding: var(--space-3) var(--space-4)`
- Icon: `AlertCircle` (16px, `var(--color-destructive)`) inline
- Copy: "Too many attempts. Please wait a moment and try again." — 13px/400,
`var(--color-destructive)`
- Submit button: disabled during rate-limit window
3. **Account locked** (persistent lockout — household scale break-glass is CLI only, D-13):
- Same banner style as rate-limit
- Copy: "This account is temporarily locked. Contact your admin to reset access."
- Submit button: disabled
4. **Generic server error** (5xx / network):
- Copy: "Something went wrong. Please try again." — Body (15px/400), `var(--color-destructive)`
- Submit button: re-enabled after error
### Surface 7 — Primary Submit Button ("Sign in")
- Filled: `background: var(--color-member-0)`, `color: #ffffff`
- Width: 100% (full-width login button — D-04 low-friction for non-technical user)
- Label: 13px/600, `fontFamily: var(--font-family-base)`
- `minHeight: 44px`, `borderRadius: var(--space-1)` (4px), `border: none`
- `transition: background 0.15s ease`
- Disabled state: `background: var(--color-border)`, `cursor: default` (during submission or lockout)
- Loading state: `Loader2` icon (16px, #ffffff, `animation: spin 1s linear infinite`) inline before
label text; label changes to "Signing in…"
- Enabled only when both username and password fields are non-empty
### Surface 8 — Method Divider (OIDC mode only)
Rendered between the local login card and the OIDC button when `oidcEnabled === true` from
`/api/auth/mode`. Not rendered when OIDC is not configured.
- A horizontal rule with centered label "or":
- `display: flex; alignItems: center; gap: var(--space-3); marginTop: var(--space-4); marginBottom: var(--space-4)`
- Left/right lines: `flex: 1; height: 1px; background: var(--color-border)`
- "or" label: 13px/400, `var(--color-text-secondary)`, `flexShrink: 0`
### Surface 9 — OIDC Login Button (OIDC mode only)
Rendered below the method divider when `oidcEnabled === true`. Not rendered when OIDC is not
configured. This is NOT inside the login card — it sits below the card, after the divider.
- Outlined style: `background: transparent; border: 1px solid var(--color-member-0); color: var(--color-member-0)`
- Width: 100% (matches Surface 7 width)
- Label: "Login with OIDC" — 13px/600 (never says "Authelia" — D-06 BYO-Auth principle)
- `minHeight: 44px`, `borderRadius: var(--space-1)`, `cursor: pointer`
- On click: initiates the OIDC authorization-code flow (same as today's redirect)
- `lucide ShieldCheck` (16px) inline before label text — represents "your SSO provider"
- No loading state needed (redirect is instant)
### Surface 10 — Forgot Password Helper
Below Surface 7 (sign-in button), inside the login card.
- A single-line text: "Forgot your password? Ask your admin." — 13px/400,
`var(--color-text-secondary)`, `textAlign: center; marginTop: var(--space-4)`
- No link — password reset is admin-only (D-11), no self-service email reset (D-11, email
out of project scope). The text is informational only; not interactive.
- This copy is non-alarming for the non-technical user: frames it as a quick admin action,
not a problem.
### Surface 11 — Admin Additions: Local Accounts Section
Extends the existing `/admin` route (AdminPage.tsx), below the "MEMBERS" section and "SHARED
CALENDAR" section. New section labeled "LOCAL ACCOUNTS" (section-label style: 13px/600/uppercase/
0.06em letter-spacing, `var(--color-text-muted)`).
**Sub-surface 11A — Create Member / Set Initial Password**
A card/form within the LOCAL ACCOUNTS section:
- Heading (inline, not a card): "Add member" — Body (15px/600/`var(--color-text-primary)`)
- Fields (same input style as CredentialSheet):
- Display name — `type="text"`, label "Display name"
- Username — `type="text"`, label "Username", `autoComplete="off"`, `spellCheck={false}`, `autoCapitalize="none"`
- Initial password — `type="password"`, label "Initial password", `autoComplete="new-password"`
- Confirm password — `type="password"`, label "Confirm password", `autoComplete="new-password"`
- Field error: inline below the specific field, 13px/400, `var(--color-destructive)`, same style as
CredentialSheet validation failure
- Submit: "Add member" — filled accent button (same style as admin Save Credential button),
`minHeight: 44px`, right-aligned in action row. Disabled when any required field is empty or
passwords do not match.
- Success: form clears; member appears in the MEMBERS section above.
- Error copy variants:
- Username already taken: "That username is already in use. Choose a different one."
- Passwords do not match: "Passwords do not match."
- Weak password (if enforced): "Password is too short. Use at least 8 characters."
**Sub-surface 11B — Admin Password Reset (per-member)**
Accessible from each member row in the MEMBERS section via a new "Reset password" action button
(alongside existing "Rotate credential"/"Add credential" buttons — shown only for members who have
a local credential row).
Opens a bottom sheet (mobile) / centered modal (desktop), identical pattern to CredentialSheet
(role="dialog", aria-modal, Escape closes, focus returns to trigger):
- Heading: "Reset password" — 18px/600
- Member subtitle: "{DisplayName}" — 15px/400, `var(--color-text-secondary)`
- Fields:
- New password — `type="password"`, `autoComplete="new-password"`, label "New password"
- Confirm new password — `type="password"`, `autoComplete="new-password"`, label "Confirm new password"
- No current-password field — admin reset does not require knowing the old password
- Action row (right-aligned, gap `var(--space-3)`):
- Cancel: ghost button (same ghostBtnStyle as CredentialSheet)
- "Reset password": filled accent button, disabled while fields empty or mismatch
- Success: sheet closes; no toast (the action is silent — admin-only, not user-visible)
- Error: inline below confirm field in `var(--color-destructive)`, 13px/400
### Surface 12 — Self Password-Change (member self-service)
Accessible from the SettingsSheet (existing Settings bottom sheet the user opens from the avatar
button). A new "Change password" row in SettingsSheet, shown only when the current user has a
local credential (`hasLocalCredential: true` from `/api/me`). Tapping opens a bottom sheet
(same pattern as CredentialSheet):
- Heading: "Change password" — 18px/600
- Fields:
- Current password — `type="password"`, `autoComplete="current-password"`, label "Current password"
- New password — `type="password"`, `autoComplete="new-password"`, label "New password"
- Confirm new password — `type="password"`, `autoComplete="new-password"`, label "Confirm"
- Action row:
- Cancel: ghost button
- "Change password": filled accent, disabled while any field empty or new/confirm mismatch
- Success: sheet closes; no toast (self-service action is low-stakes confirmation)
- Error variants:
- Wrong current password: "Current password is incorrect."
- Passwords do not match: "Passwords do not match."
- Generic error: "Something went wrong. Please try again."
- `aria-describedby` on each field pointing to the specific inline error
### Surface 13 — Link OIDC Identity (per-user action)
Shown in SettingsSheet for the currently authenticated user, only when:
- The user has a local credential (is a local user, not already OIDC-only)
- OIDC is enabled (`oidcEnabled === true` from app state)
Entry point: a "Link OIDC identity" row in SettingsSheet, below "Change password" (if shown).
Tapping opens a **confirmation bottom sheet** (not a form — the actual linking happens via OIDC
redirect, so the sheet just explains consequences):
- Heading: "Link OIDC identity" — 18px/600
- Body (15px/400, `var(--color-text-secondary)`, `lineHeight: 1.5`):
"After linking, you'll sign in with your OIDC provider instead of a username and password.
Your local password will be removed."
- This is informational, not alarming: frame as an upgrade, not a removal.
- Do NOT use the word "delete" or "remove" in the primary copy.
- A secondary note in `var(--color-text-muted)` 13px/400:
"This can't be undone from the app. Contact your admin if you need to revert."
- Action row:
- "Cancel" ghost button
- "Continue with OIDC" filled accent button (D-06: never "Continue with Authelia")
- On "Continue with OIDC": sheet closes; OIDC authorization-code flow initiates.
On callback, backend binds `iss+sub` to the user and deletes the `local_credentials` row (D-12).
User is then redirected to `/calendar` as a now-OIDC-only user.
- If the OIDC `iss+sub` already belongs to another user: the callback returns a 409 error.
The PWA shows a generic error page: "This OIDC identity is already linked to another account.
Please contact your admin." (not shown in the sheet — occurs post-redirect)
---
## Routing & App-Level Gate
1. On app load, `GET /api/auth/mode` is fetched pre-auth (before OIDC middleware, no session
required). Returns: `{ localEnabled: true, oidcEnabled: boolean }`.
2. If the user has a valid session (any method): skip `/login`, proceed to normal app routes.
3. If no valid session AND `localEnabled === true`: render `/login` (Surface 1).
4. If no valid session AND `localEnabled === false` AND `oidcEnabled === true`: initiate OIDC
redirect directly (no login page shown — OIDC-only mode, today's behavior).
5. The `/login` route does NOT render inside the normal App shell — no AppNav, no BottomTabBar.
The existing `AuthSplash` component (spinner + "Signing you in") continues to be shown during
any auth-state loading before the login page is reached.
The Phase 12 setup gate (`/api/setup/status`) takes priority: if `setupComplete === false`, the
app redirects to `/setup` before reaching the login gate.
---
## Interaction Contract
### Login form state machine
```
fields empty → Submit disabled
username OR password empty → Submit disabled
both fields non-empty → Submit enabled
submit tapped → loading state (Loader2 spinner, "Signing in…", submit disabled)
success → navigate to /calendar (cookie set by API)
401 invalid credentials → error state (Surface 6, variant 1); fields remain editable; reset loading
429 rate limit → error state (Surface 6, variant 2); submit temporarily disabled
423 locked → error state (Surface 6, variant 3); submit disabled
5xx / network → error state (Surface 6, variant 4); submit re-enabled
```
### Password show/hide
Toggle button (Surface 5): clicking switches `type` between `"password"` and `"text"`.
The toggle state resets to hidden (`type="password"`) when the field loses focus.
`aria-pressed` reflects current show state.
### OIDC button (Surface 9)
Rendered only when `oidcEnabled === true`. Clicking initiates OIDC authorization-code flow
(same redirect as today). No loading state — the redirect is immediate.
### Focus management
- On page mount, focus moves to the username field (autofocus — login form is the only content)
- On submit error, focus moves to the heading of Surface 6 (`tabIndex={-1}`, `ref` + `.focus()`)
- On Enter key in username field: focus moves to password field
- On Enter key in password field: submit fires (if button not disabled)
### Keyboard-only login
The entire login form is keyboard-navigable. Tab order: username → password → show/hide toggle →
"Sign in" button → "Login with OIDC" button (if shown). No tab traps outside the OIDC
confirmation sheet.
---
## Copywriting Contract
### Login Screen (Surface 110)
| Element | Copy |
|---------|------|
| App name in brand slot | "FamilySync" |
| App tagline in brand slot | "Family calendar & lists" |
| Login card heading | "Sign in" |
| Username field label | "Username" |
| Password field label | "Password" |
| Show password toggle aria-label | "Show password" |
| Hide password toggle aria-label | "Hide password" |
| Primary CTA | "Sign in" |
| Primary CTA loading state | "Signing in…" |
| Forgot password helper | "Forgot your password? Ask your admin." |
| Method divider label | "or" |
| OIDC button label | "Login with OIDC" |
| Error — invalid credentials | "Incorrect username or password." |
| Error — rate limit | "Too many attempts. Please wait a moment and try again." |
| Error — account locked | "This account is temporarily locked. Contact your admin to reset access." |
| Error — server/network | "Something went wrong. Please try again." |
| Empty state | N/A — login form always has explicit content |
### Admin Additions (Surfaces 1113)
| Element | Copy |
|---------|------|
| Section label | "LOCAL ACCOUNTS" |
| Add member form heading | "Add member" |
| Display name field label | "Display name" |
| Username field label | "Username" |
| Initial password field label | "Initial password" |
| Confirm password field label | "Confirm password" |
| Add member submit button | "Add member" |
| Error — username taken | "That username is already in use. Choose a different one." |
| Error — passwords mismatch (create) | "Passwords do not match." |
| Error — password too short | "Password is too short. Use at least 8 characters." |
| Admin reset sheet heading | "Reset password" |
| Admin reset new password label | "New password" |
| Admin reset confirm label | "Confirm new password" |
| Admin reset submit button | "Reset password" |
| SettingsSheet — change password row | "Change password" |
| Self-change sheet heading | "Change password" |
| Self-change current password label | "Current password" |
| Self-change new password label | "New password" |
| Self-change confirm label | "Confirm" |
| Self-change submit button | "Change password" |
| Self-change error — wrong current | "Current password is incorrect." |
| Self-change error — passwords mismatch | "Passwords do not match." |
| SettingsSheet — link OIDC row | "Link OIDC identity" |
| Link OIDC sheet heading | "Link OIDC identity" |
| Link OIDC sheet body | "After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed." |
| Link OIDC secondary note | "This can't be undone from the app. Contact your admin if you need to revert." |
| Link OIDC cancel button | "Cancel" |
| Link OIDC confirm button | "Continue with OIDC" |
| Link OIDC post-redirect error (409) | "This OIDC identity is already linked to another account. Please contact your admin." |
| Admin member row CTA — reset (local user) | "Reset password" |
| Generic admin error | "Something went wrong. Please try again." |
### Copywriting rules (D-06 BYO-Auth principle)
- Never use the word "Authelia" in any user-facing copy. Use "your OIDC provider" or
"Login with OIDC" everywhere.
- Never say "delete" or "remove" when describing the OIDC-link consequence — use
"your local password will be removed" (passive, factual, non-alarming).
- Admin copy ("Reset password") is direct — admins are comfortable with technical vocabulary.
- End-user copy ("Sign in", "Forgot your password? Ask your admin.") is warm and low-friction —
optimized for the non-technical Apple household member.
---
## Destructive Actions
| Action | Trigger | Confirmation approach |
|--------|---------|----------------------|
| Link OIDC identity (removes local credential for that user) | "Link OIDC identity" in SettingsSheet → "Continue with OIDC" tap | Two-step: open confirmation sheet (step 1, explains consequence) + explicit "Continue with OIDC" tap (step 2). The confirmation sheet clearly states "your local password will be removed." No additional modal/dialog beyond this sheet. |
| Admin password reset | "Reset password" in admin member row → sheet submit | Two-step: open reset sheet (step 1) + explicit "Reset password" tap with filled-in new password (step 2). No separate confirmation dialog — the act of filling and submitting a new value is the acknowledgement. |
No hard-delete of local accounts in this phase. Account removal is out of scope.
---
## Accessibility Contract
### Login page (Surfaces 110)
- `role="main"` on the content column
- `<h1>` is the app name "FamilySync" in the brand slot (page-level heading);
`<h2>` is "Sign in" (login card heading)
- Username input: `id="login-username"`, `<label htmlFor="login-username">`, `spellCheck={false}`,
`autoCapitalize="none"`, `autoCorrect="off"`
- Password input: `id="login-password"`, `<label htmlFor="login-password">`, `aria-describedby="login-error"` (when error active)
- Error container: `id="login-error"`, `role="status"`, `aria-live="polite"`, `aria-atomic="true"`
screen readers announce errors without focus movement
- Show/hide toggle: `aria-pressed`, `aria-label="Show password"` / `"Hide password"`, 44px tap target
- Submit button: `disabled` attribute (not just `pointer-events: none`) when disabled
- Focus on mount: `autoFocus` on username field
- Focus management on error: move focus to error heading (`tabIndex={-1}`, `.focus()`)
- OIDC button: `type="button"`, descriptive label (no ambiguous icon-only)
- Focus ring: `var(--color-focus-ring)` (#4a90d9), 2px outline, 2px offset on all focusable elements
### Admin additions (Surfaces 1113)
- All sheets: `role="dialog"`, `aria-modal="true"`, `aria-label` matching heading, Escape closes,
focus returns to trigger element on close
- All password fields: `type="password"`, correct `autoComplete` values (never cross-contaminate
new-password / current-password)
- Field errors: `aria-describedby` from input to its specific inline error element
- Sheet heading: `<h2>` (heading hierarchy under page `<h1>`)
- Minimum touch targets: `minHeight: 44px; minWidth: 44px` on all buttons
---
## Responsive Behavior
The login page is **phone-first** (the primary user is on mobile — CLAUDE.md hard UX constraint).
- Phone (<768px): card fills viewport minus `var(--space-6)` horizontal padding (12px each side);
brand slot centered; no bottom tab bar; no AppNav
- Desktop (≥768px): card centered at maxWidth 400px; brand slot centered above it
- At all breakpoints: no AppNav, no BottomTabBar rendered on the login page
Admin additions (Surfaces 1113) follow the existing AdminPage responsive pattern:
- Mobile: bottom sheet for all sheets (borderRadius 12px top corners, slides up)
- Desktop: centered modal (maxWidth 480px, same as CredentialSheet)
- Add-member form (Surface 11A) is inline within `/admin` content, not a sheet
---
## Security Display Rules
Hard UI rules — not implementation notes:
- Password fields always render as `type="password"` initially — show/hide is explicit user action
- No password is ever pre-filled, echoed, or returned to the UI after save
- Password values are never written to localStorage, sessionStorage, or any client-side store
- Error messages for invalid credentials do NOT indicate which field is wrong
(timing-safe: same copy for "wrong username" and "wrong password")
- No `dangerouslySetInnerHTML` anywhere on the login page (project convention T-05-24)
- The OIDC button label never contains provider-specific branding that would leak infrastructure
details (D-06)
- The "Link OIDC identity" flow is only accessible to an already-authenticated local user —
never from the unauthenticated login page
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none — not initialized | not applicable |
| third-party | none | not applicable |
No third-party component registries. All components hand-rolled following existing project
convention. No new npm dependencies for UI are required beyond lucide-react (already installed;
new icons needed: `Lock`, `User`, `Eye`, `EyeOff`, `LogIn` — all available in lucide-react).
---
## Pre-Population Sources
| Decision | Source | Value |
|----------|--------|-------|
| Spacing scale | apps/pwa/src/styles/tokens.css | --space-1 through --space-12; no new tokens |
| Typography scale | apps/pwa/src/styles/tokens.css | 4 sizes (13/15/18/24px), 2 weights (400/600) |
| Color palette | apps/pwa/src/styles/tokens.css | All hex values; no new colors |
| Component library | apps/pwa convention | Hand-rolled inline React.CSSProperties; no shadcn |
| Icon library | apps/pwa imports | lucide-react (already installed) |
| Full-page shell layout | SetupPage.tsx | pageStyle, contentColStyle, cardStyle, primaryBtnStyle, ghostBtnStyle, inputStyle, labelStyle, helperStyle |
| Validation row pattern | SetupPage.tsx | ValidationRow component (idle/pending/success/failure) |
| Admin section label style | AdminPage.tsx | sectionLabelStyle (13px/600/uppercase/0.06em) |
| Bottom sheet pattern | CredentialSheet.tsx | role="dialog", aria-modal, Escape, focus-return, borderRadius 12px top |
| Button styles | SetupPage.tsx / AdminPage.tsx | Filled accent + ghost button — exact match |
| Input style | SetupPage.tsx | Same inputStyle(hasError) — border switches to destructive on error |
| No OIDC-specific branding | CONTEXT.md D-06 | Never "Authelia"; use "Login with OIDC" / "your OIDC provider" |
| Local-only + OIDC-optional coexistence | CONTEXT.md D-01/D-02 | localEnabled always true; oidcEnabled from /api/auth/mode |
| OIDC link removes local credential | CONTEXT.md D-12 | Confirmation sheet required; copy non-alarming |
| No email password reset | CONTEXT.md D-11 | "Ask your admin" copy only |
| Admin creates accounts only (no self-signup) | CONTEXT.md D-10 | Add member form is admin-only |
| Stateless JWT session cookie | CONTEXT.md D-05 | No session table UI; logout = clear cookie |
| Break-glass is CLI/env only | CONTEXT.md D-13 | No break-glass UI in scope |
| Phase 17 brand slot seam | cross-phase directive | BrandSlot component + CSS asset tokens defined |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS
- [x] Phase 17 Brand-Slot Readiness: PASS
**Approval:** approved 2026-06-16