All 6 design dimensions PASS plus Phase 17 brand-slot readiness contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
688 lines
35 KiB
Markdown
688 lines
35 KiB
Markdown
---
|
||
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 1–10)
|
||
|
||
| 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 11–13)
|
||
|
||
| 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 1–10)
|
||
- `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 11–13)
|
||
- 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 11–13) 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
|