Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
35 KiB
phase, slug, status, shadcn_initialized, preset, created
| phase | slug | status | shadcn_initialized | preset | created |
|---|---|---|---|---|---|
| 19 | local-auth-no-oidc-mode | draft | false | none | 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:
- 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.
- A login-method chooser rendered when OIDC is also configured (D-02) — local form OR "Login with OIDC" (generic, never says "Authelia" — D-06).
- Admin-surface additions (in-app shell
/adminroute, 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
sectionLabelStylepattern) - 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) Loader2spinner 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 linevar(--color-border-subtle)#eceef2 — section dividers in admin additionsvar(--color-text-primary)#111318 — headings, field values, app namevar(--color-text-secondary)#6b7280 — descriptions, helper text, tagline, divider labelvar(--color-text-muted)#9ca3af — placeholder text, inactive admin rowsvar(--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):
: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:
- On app load,
GET /api/auth/mode(pre-auth endpoint — no session required) returns{ localEnabled: true, oidcEnabled: boolean }. - If the user already has a valid session (local JWT cookie or OIDC session), they are
redirected to
/calendarbefore the login page renders. - The
/loginroute renders the<LoginPage>(full-viewport, no shell). - 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 activespellCheck={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 lucideEye(show) orEyeOff(hide), 16px,aria-label="Show password"/"Hide password",aria-pressedreflects 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:
-
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)
- Icon:
-
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
- Background:
-
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
-
Generic server error (5xx / network):
- Copy: "Something went wrong. Please try again." — Body (15px/400),
var(--color-destructive) - Submit button: re-enabled after error
- Copy: "Something went wrong. Please try again." — Body (15px/400),
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: nonetransition: background 0.15s ease- Disabled state:
background: var(--color-border),cursor: default(during submission or lockout) - Loading state:
Loader2icon (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"
- Display name —
- 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"
- 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"
- Current password —
- 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-describedbyon 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 === truefrom 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+subto the user and deletes thelocal_credentialsrow (D-12). User is then redirected to/calendaras a now-OIDC-only user. - If the OIDC
iss+subalready 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
- On app load,
GET /api/auth/modeis fetched pre-auth (before OIDC middleware, no session required). Returns:{ localEnabled: true, oidcEnabled: boolean }. - If the user has a valid session (any method): skip
/login, proceed to normal app routes. - If no valid session AND
localEnabled === true: render/login(Surface 1). - If no valid session AND
localEnabled === falseANDoidcEnabled === true: initiate OIDC redirect directly (no login page shown — OIDC-only mode, today's behavior). - The
/loginroute 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:
disabledattribute (not justpointer-events: none) when disabled - Focus on mount:
autoFocuson 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-labelmatching heading, Escape closes, focus returns to trigger element on close - All password fields:
type="password", correctautoCompletevalues (never cross-contaminate new-password / current-password) - Field errors:
aria-describedbyfrom input to its specific inline error element - Sheet heading:
<h2>(heading hierarchy under page<h1>) - Minimum touch targets:
minHeight: 44px; minWidth: 44pxon 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
/admincontent, 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
dangerouslySetInnerHTMLanywhere 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