- 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)
201 lines
12 KiB
Markdown
201 lines
12 KiB
Markdown
---
|
|
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
|