Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 |
|
|
|
|
|
|
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 displayAdminMember,SaveCredentialPayload,AdminCalendar,SaveMyCredentialPayloadtypes matching Plan-03 shapesfetchAdminMembers()→ GET /api/admin/memberssaveCredential(payload)→ POST /api/admin/credentials (includes userId — admin-scoped)fetchAdminCalendars()→ GET /api/admin/calendarssetSharedCalendar(calendarId)→ PUT /api/admin/calendars/:id/sharedsaveMyCredential(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 borderRadiustype="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:
/adminRoute gated:meQuery.isLoading → <div aria-hidden>(no flash),isAdmin → <AdminPage />, else<Navigate to="/calendar" replace /><SetupBanner />mounted above Routes in the content areaisAdminprop forwarded to AppNav and BottomTabBar
AppNav.tsx + BottomTabBar.tsx:
- ShieldCheck (size 18/22) Admin entry with
aria-label="Admin settings"rendered ONLY whenisAdmin === true - Conditional import of ShieldCheck from lucide-react
admin.spec.ts (apps/pwa/e2e/admin.spec.ts):
- Admin user (seeded
is_admin=trueby global-setup): nav entry visible, /admin renders heading + Members section - Non-admin (route-mocked
isAdmin:false): no nav entry, /admin redirects to /calendar viawaitForURL - 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 ? nullrenders 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.tsxexists with > 60 lines: PASSapps/pwa/src/components/CredentialSheet.tsxexists with > 50 lines: PASSapps/pwa/src/components/SetupBanner.tsxexists with > 20 lines: PASSgrep "isAdmin" apps/pwa/src/api/client.tsmatches ≥ 2 occurrences: PASSgrep "needsProviderSetup" apps/pwa/src/api/client.tsmatches: PASSgrep "autoComplete" apps/pwa/src/components/CredentialSheet.tsxcontains "new-password": PASSgrep "invalidateQueries" apps/pwa/src/components/CredentialSheet.tsx≥ 2 occurrences: PASSgrep "role=\"status\"" apps/pwa/src/components/SetupBanner.tsxexists: PASSgrep "ShieldCheck" apps/pwa/src/components/AppNav.tsxexists: PASSgrep "ShieldCheck" apps/pwa/src/components/BottomTabBar.tsxexists: PASSgrep "Navigate to=\"/calendar\"" apps/pwa/src/App.tsxexists: PASSpnpm --filter @familysync/pwa typecheckexits 0 (both app + e2e tsconfigs): PASSpnpm --filter @familysync/pwa buildexits 0: PASSpnpm --filter @familysync/pwa lintexits 0: PASSpnpm --filter @familysync/pwa test191/191 pass: PASS- e2e admin.spec.ts 15/15 pass (iphone + pixel + desktop): PASS
- Commits
bfe1eff,2c2c71e,7808426,79fe3e0in git log: PASS