diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md b/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md new file mode 100644 index 0000000..f404c23 --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-UI-SPEC.md @@ -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 `` in the login page — not inline JSX. +This isolates the seam: Phase 17 replaces `` internals (swap placeholder div for +``) without touching `` 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 `` internals (image src) + set CSS custom property +values. No changes to `` 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 `` (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 +- `

` is the app name "FamilySync" in the brand slot (page-level heading); + `

` is "Sign in" (login card heading) +- Username input: `id="login-username"`, `