--- 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
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 →
` (no flash), `isAdmin → `, else `` - `` 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 ?
` 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` 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