---
phase: 10
slug: admin-role-settings
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-13
---

# Phase 10 — Admin Role & Settings UI Design Contract

> Visual and interaction contract for the admin role, credential management, shared-calendar designation, and member self-service credential onboarding surfaces. Generated by gsd-ui-researcher.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none — custom CSS variables only |
| Preset | not applicable |
| Component library | none (hand-authored inline styles, `var(--token)` pattern throughout) |
| Icon library | lucide-react 1.17.0 |
| Font | `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif` (var(--font-family-base)) |

Source: `apps/pwa/src/styles/tokens.css` (Phase 2, D-01/D-02). No shadcn, no Tailwind — all values are CSS custom properties declared in tokens.css and referenced inline.

---

## Spacing Scale

All spacing must reference `var(--space-N)` tokens, never hard-coded px. Multiples of 4px.

| Token | Value | Usage |
|-------|-------|-------|
| --space-1 | 4px | Icon gaps, tight inline padding, border-radius on small elements |
| --space-2 | 8px | Compact row padding, section-label bottom margin, badge gap |
| --space-3 | 12px | Row internal padding (toggle rows, member rows), gap between icon and label |
| --space-4 | 16px | Input horizontal padding, button horizontal padding, nav horizontal padding |
| --space-6 | 24px | Sheet/page padding, heading bottom margin, section separation |
| --space-8 | 32px | Major layout gaps between sections |
| --space-12 | 48px | Page-level top/bottom breathing room on the /admin route |

Exceptions: 44px minimum touch target on all interactive elements (buttons, radio pills, credential rows) — applied as `minWidth: 44px; minHeight: 44px` inline, not a spacing token. Destructive confirm button uses `minHeight: 48px` per the existing DeleteConfirmationDialog precedent.

---

## Typography

All values reference existing `var(--text-*)` tokens from tokens.css. No new sizes or weights.

| Role | Size | Weight | Line Height | Usage in this phase |
|------|------|--------|-------------|---------------------|
| Body | 15px (var(--text-body-size)) | 400 (var(--text-body-weight)) | 1.5 (var(--text-body-line-height)) | Member name, credential status description, instruction copy, field labels |
| Label | 13px (var(--text-label-size)) | 400 (var(--text-label-weight)) | 1.4 (var(--text-label-line-height)) | Section headers (uppercased), status badges, calendar picker option text, helper text under password field |
| Heading | 18px (var(--text-heading-size)) | 600 (var(--text-heading-weight)) | 1.25 (var(--text-heading-line-height)) | Page heading "Admin Settings", sheet headings ("Rotate Credential", "Set Shared Calendar"), confirmation dialog heading |
| Display | 24px (var(--text-display-size)) | 600 (var(--text-display-weight)) | 1.2 (var(--text-display-line-height)) | Not used in this phase — reserved for app name in AppNav |

Section labels (e.g. "MEMBERS", "SHARED CALENDAR") follow the established SettingsSheet pattern: 13px / weight 600 / `var(--color-text-muted)` / `textTransform: uppercase` / `letterSpacing: 0.06em`.

---

## Color

All values reference existing tokens from tokens.css. No new colors introduced.

| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | #ffffff (var(--color-surface)) | /admin page background, sheet backgrounds, dialog backgrounds |
| Secondary (30%) | #f7f7f8 (var(--color-surface-dim)) | Member rows background on hover/focus, info/hint banners, credential-setup onboarding card |
| Accent (10%) | #4a90d9 (var(--color-member-0)) | Save/confirm CTA buttons, active radio selection border, focus ring (var(--color-focus-ring)), selected calendar radio indicator |
| Destructive | #dc2626 (var(--color-destructive)) | "Remove credential" destructive action button only |

Accent reserved for: Save button ("Save Credential"), selected-calendar radio indicator, text links ("Get an app password" → Fastmail doc link), focus ring on inputs and interactive elements. Never used for nav chrome, page background, or passive text.

Status indicators (member credential state) use the established muted/secondary palette, not accent:
- Credential set: `var(--color-text-secondary)` + a check icon (lucide `CheckCircle`, size 16)
- No credential: `var(--color-text-muted)` + warning icon (lucide `AlertCircle`, size 16)

---

## Surfaces & Interaction Patterns

### Surface 1 — /admin Route (full page)

A dedicated route at `/admin`, gated by `isAdmin` from `/api/me`. Non-admin users redirected to `/calendar` immediately on mount.

Layout matches the existing app shell: AppNav persistent sidebar (desktop) or top bar (mobile), BottomTabBar (mobile). The `/admin` content area uses `var(--color-surface)` background with `var(--space-12)` top/bottom padding and `var(--space-6)` horizontal padding on mobile.

Desktop: content in a centered column, `maxWidth: 640px`, `margin: 0 auto`.

The admin route does NOT appear in AppNav nav links by default. Entry point: a new "Admin" `NavLink` in the desktop sidebar and a new tab in the mobile BottomTabBar, rendered only when `meQuery.data?.isAdmin === true`. Use lucide `ShieldCheck` icon (size 18) for the Admin nav entry, matching the `CalendarDays`/`List` pattern in AppNav.

### Surface 2 — Member Credential List Section

A section within `/admin` labeled "MEMBERS" (section-label style).

Each member renders as a row:
- Avatar color swatch (32px circle, `var(--color-member-N)`) + member display name at body size
- Credential status badge at label size: "Credential set" (muted green check) or "No credential" (muted warning)
- An action button: "Rotate" (if credential exists) or "Add credential" (if none)
- Entire row: `minHeight: 44px`, background `var(--color-surface)`, bottom border `1px solid var(--color-border-subtle)`

Clicking "Rotate" or "Add credential" opens Surface 3 (credential sheet) for that member.

Admin can rotate any member's credential. Members can only manage their own (self-service path via Surface 4 — same sheet, member-scoped API endpoint).

### Surface 3 — Credential Sheet (admin-managed rotation)

A bottom sheet on mobile (same pattern as SettingsSheet: `role="dialog"`, `aria-modal`, zIndex 301, backdrop zIndex 300, `borderRadius: 12px 12px 0 0`, `padding: var(--space-6)`). Centered modal on desktop (`maxWidth: 480px`).

Contents:
1. Heading: "Rotate Credential" (if existing) or "Add Credential" (if none) — 18px/600
2. Member name as subtitle — 15px/400/`var(--color-text-secondary)`
3. Password field (type="password", autocomplete="new-password"):
   - Label: "App password" — 13px/600/`var(--color-text-primary)`
   - Input: full-width, `padding: var(--space-3) var(--space-4)`, `border: 1px solid var(--color-border)`, `borderRadius: var(--space-1)`, `fontSize: var(--text-body-size)`, `color: var(--color-text-primary)`, `background: var(--color-surface)`. Error state border: `var(--color-destructive)`.
   - Never pre-filled; never echoed back after save.
4. Helper text below field — 13px/400/`var(--color-text-secondary)`:
   "Enter the Fastmail app password scoped to Calendars/CalDAV. [Get an app password](https://app.fastmail.com/settings/security/devicetokens) — choose the 'Calendars & Contacts (CalDAV)' scope."
   The link opens in a new tab (`target="_blank" rel="noopener noreferrer"`).
5. Validation: "Validating against CalDAV…" inline status (13px, muted, spinner `Loader2` size 16) replaces helper text during the PROPFIND call. On failure: red 13px error below field ("Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again."). On success: sheet closes.
6. Actions row (right-aligned, gap `var(--space-3)`):
   - Cancel: ghost button (background none, 13px/600/`var(--color-text-secondary)`, minHeight 44px)
   - Save Credential: filled accent button (`var(--color-member-0)` background, #ffffff text, 13px/600, minHeight 44px, `padding: 0 var(--space-4)`, `borderRadius: var(--space-1)`). Disabled while field empty or during validation.

### Surface 4 — Self-Service Credential Onboarding (member-scoped)

Triggered when a member with `needsProviderSetup: true` (from `/api/me`) loads the app. Rendered as a dismissable banner or inline card above the calendar content — NOT a modal (user should be able to continue using the app without completing it immediately).

Card/banner style: `background: var(--color-surface-dim)`, `border: 1px solid var(--color-border)`, `borderRadius: var(--space-2)`, `padding: var(--space-4)`, `margin: var(--space-4)`.

Contents:
- Icon: lucide `KeyRound` size 20, `var(--color-member-0)`
- Heading: "Set up your calendar" — 15px/600/`var(--color-text-primary)`
- Body: "To sync your Fastmail calendar, you need to add an app password. This takes about a minute." — 13px/400/`var(--color-text-secondary)`
- CTA button: "Set up now" — same accent-filled style as Surface 3 Save button, minHeight 44px

Clicking "Set up now" opens the same credential sheet (Surface 3) but scoped to the current user only, with heading "Add your calendar credential" and simplified copy ("App password for your Fastmail account").

The banner has no X/dismiss button — it stays visible until the credential is successfully saved (needsProviderSetup becomes false after save).

### Surface 5 — Shared Calendar Picker Section

A section within `/admin` labeled "SHARED CALENDAR" (section-label style), below the Members section.

Body copy (15px/400/secondary): "The shared family calendar is visible to all members in the same color lane."

Radio group — one row per synced calendar:
- Each row: `minHeight: 44px`, flexbox, `gap: var(--space-3)`, `padding: var(--space-2) 0`
- Radio indicator: a 20px circle — unfilled with `2px solid var(--color-border)` when unselected; filled with `var(--color-member-0)` + inner 8px white dot when selected
- Calendar name at body size (15px/400/`var(--color-text-primary)`)
- "Currently shared" label (13px/400/`var(--color-member-0)`) on the currently active selection

Selection is exclusive single-select (D-06). Selecting a row immediately highlights it; a "Save" button below the list confirms the write (two-tap, prevents accidental mis-selection).

Save button: full-width on mobile, right-aligned on desktop; accent-filled style, `minHeight: 44px`. Disabled until selection differs from current saved value.

If no calendars have been synced yet: empty state (see Copywriting Contract below).

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Admin nav label | "Admin" |
| /admin page heading | "Admin Settings" |
| Members section label | "MEMBERS" |
| Shared calendar section label | "SHARED CALENDAR" |
| Credential status — set | "Credential set" |
| Credential status — missing | "No credential" |
| Admin credential row CTA — existing | "Rotate" |
| Admin credential row CTA — none | "Add credential" |
| Credential sheet heading — admin rotation (existing) | "Rotate Credential" |
| Credential sheet heading — admin add (none) | "Add Credential" |
| Credential sheet heading — self-service | "Add your calendar credential" |
| Credential sheet member subtitle | "{DisplayName}" |
| Password field label | "App password" |
| Password field helper text | "Enter the Fastmail app password scoped to Calendars/CalDAV. [Get an app password] — choose the 'Calendars & Contacts (CalDAV)' scope." |
| CalDAV validation in-progress | "Validating against CalDAV…" |
| CalDAV validation failure | "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again." |
| Save credential button | "Save Credential" |
| Cancel button | "Cancel" |
| Self-service banner heading | "Set up your calendar" |
| Self-service banner body | "To sync your Fastmail calendar, you need to add an app password. This takes about a minute." |
| Self-service CTA button | "Set up now" |
| Shared calendar save button | "Save" |
| Shared calendar currently-active label | "Currently shared" |
| Shared calendar empty state heading | "No calendars synced yet" |
| Shared calendar empty state body | "Calendars sync automatically. Check back after the first sync completes." |
| Calendars picker loading | "Loading calendars…" |
| Generic save error | "Something went wrong. Please try again." |
| 403 non-admin redirect | (silent redirect — no error copy shown to the non-admin user) |

Destructive actions in this phase:

| Action | Trigger | Confirmation approach |
|--------|---------|----------------------|
| Rotating/replacing a credential | Tapping "Rotate" then "Save Credential" with a new value | Two-step: open sheet (step 1) + explicit "Save Credential" tap (step 2). No separate confirmation dialog — overwrite is acknowledged by the user filling and submitting the new value. The existing credential is never displayed; losing it is not destructive (a new one replaces it). |

No hard-delete of credentials in this phase. "Remove credential" is listed as the destructive color token usage but the action itself is deferred — only add/rotate is in scope for Phase 10.

---

## Accessibility Contracts

- All interactive elements: `minWidth: 44px; minHeight: 44px` (WCAG 2.5.5 Target Size).
- Credential sheet: `role="dialog"`, `aria-modal="true"`, `aria-label` matching the heading, Escape closes.
- Password input: `type="password"`, `autocomplete="new-password"`, never `autocomplete="current-password"`.
- Radio group for calendar picker: each row has `role="radio"` or wraps a native `<input type="radio">` in a visually styled label; `aria-checked` on custom implementations.
- Admin nav entry: `aria-label="Admin settings"` on the NavLink/button.
- Self-service banner: `role="status"` or `aria-live="polite"` so screen readers announce it on load.
- Validation error messages: associated to their input via `aria-describedby`.
- Focus returns to the trigger element when a sheet closes.

---

## 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-authored using the existing inline-style pattern. No new npm dependencies for UI are required beyond lucide-react (already installed at 1.17.0).

---

## 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
