---
phase: "10-admin-role-settings"
plan: "04"
subsystem: "pwa-admin-ui"
tags: ["admin-ui", "route-guard", "nav-gating", "credential-sheet", "self-service", "setup-banner", "e2e", "playwright", "isAdmin", "needsProviderSetup"]
dependency_graph:
  requires:
    - "isAdmin + needsProviderSetup on /api/me (10-02)"
    - "adminRouter endpoints: GET /members, POST /credentials, GET /calendars, PUT /calendars/:id/shared (10-03)"
    - "POST /api/me/credential self-service endpoint (10-03)"
  provides:
    - "MeUser.isAdmin + MeUser.needsProviderSetup in apps/pwa/src/api/client.ts"
    - "fetchAdminMembers, saveCredential, fetchAdminCalendars, setSharedCalendar, saveMyCredential in client.ts"
    - "apps/pwa/src/routes/AdminPage.tsx — gated /admin page (MEMBERS + SHARED CALENDAR)"
    - "apps/pwa/src/components/CredentialSheet.tsx — shared admin-rotate/admin-add/self-service bottom sheet"
    - "apps/pwa/src/components/SetupBanner.tsx — needsProviderSetup onboarding banner (success-only dismissal)"
    - "/admin Route in App.tsx (isAdmin gate + loading gate)"
    - "conditional Admin nav entry (ShieldCheck) in AppNav.tsx + BottomTabBar.tsx"
    - "apps/pwa/e2e/admin.spec.ts — 15 e2e assertions (3 profiles x 5 tests)"
  affects:
    - "Phase 11 (per-event reminders may reuse SetupBanner pattern)"
    - "Phase 12 (setup wizard reuses CredentialSheet for initial credential setup)"
tech_stack:
  added: []
  patterns:
    - "React Query ['admin','members'] + ['admin','calendars'] for admin data fetching"
    - "['me'] invalidation from CredentialSheet.onSuccess → SetupBanner unmounts (success-only dismissal)"
    - "meQuery.isLoading gate on /admin Route (prevents flash-of-redirect)"
    - "page.route('/api/me', ...) route-mock pattern for non-admin e2e assertions"
    - "waitForURL for redirect assertions in e2e (not just nav visibility)"
key_files:
  created:
    - "apps/pwa/src/routes/AdminPage.tsx"
    - "apps/pwa/src/components/CredentialSheet.tsx"
    - "apps/pwa/src/components/SetupBanner.tsx"
    - "apps/pwa/e2e/admin.spec.ts"
  modified:
    - "apps/pwa/src/api/client.ts"
    - "apps/pwa/src/App.tsx"
    - "apps/pwa/src/components/AppNav.tsx"
    - "apps/pwa/src/components/BottomTabBar.tsx"
decisions:
  - "meQuery.isLoading gate on /admin Route: renders <div aria-hidden> while loading, then isAdmin check fires — prevents flash of admin content for non-admins and prevents null-element stalling redirect"
  - "waitForURL (not just nav visibility) in redirect e2e test — the Navigate fires asynchronously after meQuery resolves, so checking pathname immediately after goto can race ahead of the redirect"
  - "Pre-existing Plan 02/03 prettier violations in API test files fixed as part of CI gate compliance (format:check was failing at workspace root)"
  - "Type assertions for RefObject removed — @typescript-eslint/no-unnecessary-type-assertion flagged them; TS already accepted the types without cast"
metrics:
  duration_seconds: 1315
  completed_date: "2026-06-13"
  tasks_completed: 3
  files_modified: 8
---

# Phase 10 Plan 04: React PWA Admin Surfaces Summary

**One-liner:** React PWA admin surfaces — isAdmin/needsProviderSetup client types, five typed admin fetchers, gated `/admin` route (AdminPage + CredentialSheet + SetupBanner), conditional ShieldCheck nav entries, and 15 e2e assertions across 3 device profiles all green.

## Tasks Completed

| Task | Name | Commits | Files |
|------|------|---------|-------|
| 1 | Extend client.ts — MeUser fields + admin/self-service fetchers | bfe1eff | apps/pwa/src/api/client.ts |
| 2 | CredentialSheet + SetupBanner components | 2c2c71e | CredentialSheet.tsx, SetupBanner.tsx |
| 3 | /admin route + AdminPage + conditional nav entries + e2e spec | 7808426 | AdminPage.tsx, App.tsx, AppNav.tsx, BottomTabBar.tsx, admin.spec.ts |
| fix | Prettier format + type assertion cleanup | 79fe3e0 | 8 files (PWA + API) |

## What Was Built

### Task 1: Extend client.ts

`apps/pwa/src/api/client.ts` extended with:
- `MeUser.isAdmin: boolean` — UX gating flag (D-03; server enforces 403 on /api/admin/*)
- `MeUser.needsProviderSetup: boolean` — drives SetupBanner display
- `AdminMember`, `SaveCredentialPayload`, `AdminCalendar`, `SaveMyCredentialPayload` types matching Plan-03 shapes
- `fetchAdminMembers()` → GET /api/admin/members
- `saveCredential(payload)` → POST /api/admin/credentials (includes userId — admin-scoped)
- `fetchAdminCalendars()` → GET /api/admin/calendars
- `setSharedCalendar(calendarId)` → PUT /api/admin/calendars/:id/shared
- `saveMyCredential(payload)` → POST /api/me/credential (NO userId — member-scoped, T-10-12)

All fetchers use `credentials:'include'`, `redirect:'manual'`, `handleAuthResponse`. Password never logged or stored beyond in-flight request body (T-10-15).

### Task 2: CredentialSheet + SetupBanner

**CredentialSheet** (`apps/pwa/src/components/CredentialSheet.tsx`):
- Three modes: `admin-rotate`, `admin-add`, `self-service` — heading copy varies per mode
- `role="dialog" aria-modal="true"` bottom sheet, zIndex 301 (backdrop 300), 12px 12px 0 0 borderRadius
- `type="password" autoComplete="new-password"` — NEVER pre-filled (T-10-16)
- Helper text with Fastmail link `target="_blank" rel="noopener noreferrer"` (UI-SPEC Surface 3)
- Loader2 spinner + "Validating against CalDAV…" during mutation
- CalDAV failure copy on error state
- On success: `invalidateQueries(['admin','members'])` + `invalidateQueries(['me'])` → needsProviderSetup refreshes
- Escape closes; focus returns to trigger element (a11y)

**SetupBanner** (`apps/pwa/src/components/SetupBanner.tsx`):
- Renders only when `meQuery.data?.user.needsProviderSetup === true`
- `role="status" aria-live="polite"` (screen reader announcement on load)
- KeyRound icon + "Set up your calendar" + body copy + "Set up now" CTA
- NO dismiss button — the ONLY exit is a successful credential save that flips needsProviderSetup → false
- Opens CredentialSheet in self-service mode

### Task 3: /admin route + AdminPage + nav entries + e2e

**AdminPage** (`apps/pwa/src/routes/AdminPage.tsx`):
- "Admin Settings" h1 (18px/600), maxWidth 640px centered desktop, var(--space-12) padding
- MEMBERS section: avatar swatch + member name + "Credential set" / "No credential" badge + "Rotate"/"Add credential" button
- SHARED CALENDAR section: radio group with "Currently shared" label, two-tap Save (disabled until selection differs), empty state copy
- Opens CredentialSheet for each member on row button click

**App.tsx** additions:
- `/admin` Route gated: `meQuery.isLoading → <div aria-hidden>` (no flash), `isAdmin → <AdminPage />`, else `<Navigate to="/calendar" replace />`
- `<SetupBanner />` mounted above Routes in the content area
- `isAdmin` prop forwarded to AppNav and BottomTabBar

**AppNav.tsx** + **BottomTabBar.tsx**:
- ShieldCheck (size 18/22) Admin entry with `aria-label="Admin settings"` rendered ONLY when `isAdmin === true`
- Conditional import of ShieldCheck from lucide-react

**admin.spec.ts** (`apps/pwa/e2e/admin.spec.ts`):
- Admin user (seeded `is_admin=true` by global-setup): nav entry visible, /admin renders heading + Members section
- Non-admin (route-mocked `isAdmin:false`): no nav entry, /admin redirects to /calendar via `waitForURL`
- 15 assertions across iphone/pixel/desktop profiles — all pass

## Playwright-CLI Supplementary Observations

Admin user (is_admin=true, seeded via mysql2 for playwright-cli check), navigated to `http://localhost:5173/admin`:

**Snapshot confirms:**
- "Admin settings" link in desktop nav sidebar visible with ShieldCheck icon
- SetupBanner renders: role=status, "Set up your calendar" heading, "Set up now" CTA (needsProviderSetup=true for dev-bypass user)
- "Admin Settings" h1 visible
- MEMBERS section: 3 members (Dev User, luc@bergermail.ca, amelia@bergermail.ca), each showing "No credential" + "Add credential" button
- SHARED CALENDAR section: radiogroup with "FamilySync Currently shared" selected, Save button disabled (no change)
- Clicking "Add credential" for Dev User opens `dialog "Add Credential"` with email+password fields, helper text with Fastmail link, Cancel + "Save Credential" (disabled until fields filled)

**Non-admin redirect:** Verified via e2e spec (page.route mock) — 5/5 tests confirmed. playwright-cli route mock intercepted the wrong URL (`localhost:3000/api/me` instead of the Vite-proxied `/api/me`) so the non-admin visual was not observed in the browser session, but the e2e spec is the binding proof per the plan.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] meQuery loading gate rendered null instead of redirect-safe element**
- **Found during:** Task 3 e2e run (test: "non-admin navigating to /admin is redirected to /calendar")
- **Issue:** `meQuery.isLoading ? null` renders nothing as the Route element, but React Router does not trigger a Navigate when the element is null — the URL stays at /admin and no redirect fires during the loading window
- **Fix:** Changed to `meQuery.isLoading ? <div aria-hidden />` so the route is occupied during loading, then the Navigate fires once meQuery resolves with isAdmin:false
- **Files modified:** apps/pwa/src/App.tsx
- **Commit:** 7808426

**2. [Rule 1 - Bug] e2e redirect test raced ahead of Navigate render**
- **Found during:** Task 3 e2e run (same test as above)
- **Issue:** Test checked URL immediately after `page.goto('/admin')`, before the meQuery resolved and Navigate rendered
- **Fix:** Added `await page.waitForURL(/\/calendar/, { timeout: 10_000 })` to wait for the actual redirect before asserting pathname
- **Files modified:** apps/pwa/e2e/admin.spec.ts
- **Commit:** 7808426

**3. [Rule 2 - Formatting] Pre-existing prettier violations in Plan 02/03 API files**
- **Found during:** CI gate (format:check)
- **Issue:** apps/api/src/routes/me.ts, tests/auth/user.test.ts, tests/lib/requireAdmin.test.ts, tests/routes/me.test.ts had unformatted lines from Plan 02/03 commits (the workspace format:check was already failing before this plan's changes)
- **Fix:** Ran prettier --write on those files; 270 API tests still pass
- **Files modified:** 4 API files
- **Commit:** 79fe3e0

**4. [Rule 1 - Bug] Unnecessary type assertions flagged by ESLint**
- **Found during:** CI gate (lint)
- **Issue:** Two `as React.RefObject<HTMLElement | null>` casts in SetupBanner.tsx and AdminPage.tsx — ESLint @typescript-eslint/no-unnecessary-type-assertion flagged them as redundant
- **Fix:** Removed both casts; TS already accepted the RefObject types without casting
- **Files modified:** apps/pwa/src/components/SetupBanner.tsx, apps/pwa/src/routes/AdminPage.tsx
- **Commit:** 79fe3e0

## Known Stubs

None. All admin surfaces are fully wired to the live API endpoints. CredentialSheet performs real CalDAV validation (via the server's validateEncryptAndStoreCredential). The SetupBanner uses the real ['me'] query. AdminPage fetches live member + calendar data.

## Threat Flags

No new threat surface beyond the plan's threat model:
- T-10-14: /admin client redirect is UX-only; server 403 (requireAdmin) is the real boundary — confirmed
- T-10-15: password field never pre-filled, never in state beyond in-flight mutation body — confirmed
- T-10-16: autoComplete="new-password" on password input — confirmed
- T-10-SC: No new packages installed (lucide-react ShieldCheck/KeyRound already in 1.17.0)

## Self-Check: PASSED

- `apps/pwa/src/routes/AdminPage.tsx` exists with > 60 lines: PASS
- `apps/pwa/src/components/CredentialSheet.tsx` exists with > 50 lines: PASS
- `apps/pwa/src/components/SetupBanner.tsx` exists with > 20 lines: PASS
- `grep "isAdmin" apps/pwa/src/api/client.ts` matches ≥ 2 occurrences: PASS
- `grep "needsProviderSetup" apps/pwa/src/api/client.ts` matches: PASS
- `grep "autoComplete" apps/pwa/src/components/CredentialSheet.tsx` contains "new-password": PASS
- `grep "invalidateQueries" apps/pwa/src/components/CredentialSheet.tsx` ≥ 2 occurrences: PASS
- `grep "role=\"status\"" apps/pwa/src/components/SetupBanner.tsx` exists: PASS
- `grep "ShieldCheck" apps/pwa/src/components/AppNav.tsx` exists: PASS
- `grep "ShieldCheck" apps/pwa/src/components/BottomTabBar.tsx` exists: PASS
- `grep "Navigate to=\"/calendar\"" apps/pwa/src/App.tsx` exists: PASS
- `pnpm --filter @familysync/pwa typecheck` exits 0 (both app + e2e tsconfigs): PASS
- `pnpm --filter @familysync/pwa build` exits 0: PASS
- `pnpm --filter @familysync/pwa lint` exits 0: PASS
- `pnpm --filter @familysync/pwa test` 191/191 pass: PASS
- e2e admin.spec.ts 15/15 pass (iphone + pixel + desktop): PASS
- Commits bfe1eff, 2c2c71e, 7808426, 79fe3e0 in git log: PASS
