--- 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 `` 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"`, `