Phase 19: Local Auth (No-OIDC Mode) #23
@@ -0,0 +1,685 @@
|
||||
---
|
||||
phase: 19
|
||||
slug: local-auth-no-oidc-mode
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 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
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user