Files
familysync/.planning/phases/10-admin-role-settings/10-04-SUMMARY.md
T
Lucas Berger 3693da6d0b docs(10-04): complete PWA admin surfaces plan
- SUMMARY.md for Plan 04 (AdminPage, CredentialSheet, SetupBanner, nav gating)
- STATE.md: plan advanced to last, progress 100%, decisions recorded
- ROADMAP.md: Phase 10 marked Complete (4/4 summaries)
2026-06-13 15:28:03 -04:00

12 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
10-admin-role-settings 04 pwa-admin-ui
admin-ui
route-guard
nav-gating
credential-sheet
self-service
setup-banner
e2e
playwright
isAdmin
needsProviderSetup
requires provides affects
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)
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)
Phase 11 (per-event reminders may reuse SetupBanner pattern)
Phase 12 (setup wizard reuses CredentialSheet for initial credential setup)
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)
created modified
apps/pwa/src/routes/AdminPage.tsx
apps/pwa/src/components/CredentialSheet.tsx
apps/pwa/src/components/SetupBanner.tsx
apps/pwa/e2e/admin.spec.ts
apps/pwa/src/api/client.ts
apps/pwa/src/App.tsx
apps/pwa/src/components/AppNav.tsx
apps/pwa/src/components/BottomTabBar.tsx
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
duration_seconds completed_date tasks_completed files_modified
1315 2026-06-13 3 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