Phase 10 — Admin Role & Settings (ADMIN-01/02/03) #17

Merged
luckberg merged 25 commits from gsd/phase-10-admin-role-settings into main 2026-06-13 17:10:47 -04:00
3 changed files with 216 additions and 12 deletions
Showing only changes of commit 3693da6d0b - Show all commits
+4 -4
View File
@@ -28,7 +28,7 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
- [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11) - [x] **Phase 7: Mobile Test Harness** - Mobile-emulated, authenticated PWA browser harness so the assistant (and CI) can catch mobile-only defects (completed 2026-06-11)
- [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11) - [x] **Phase 8: Gitea CI** - Full regression on PR to main (lint/typecheck/unit/API-integration vs a MariaDB service container **+ the Phase 7 mobile harness as a UI-regression step against a CI-hosted dev stack**) + Docker image publish on merge (completed 2026-06-11)
- [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12) - [x] **Phase 9: Faster Write-Back** - Event-driven outbox drain so edits land in ~1-2s instead of ~15s, preserving every outbox durability guarantee (completed 2026-06-12)
- [ ] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar - [x] **Phase 10: Admin Role & Settings** - DB foundation (is_admin / reminder_lead / app_config) + role-gated admin UI to rotate app passwords and designate the shared calendar (completed 2026-06-13)
- [ ] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice - [ ] **Phase 11: Per-Event Reminders** - Reminder selector on the event form (incl. "None") serialized as VALARM, with a variable-lead scheduler that honors each event's choice
- [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface - [ ] **Phase 12: Initial Setup Wizard** - First-run validated bootstrap of env/VAPID/DB/OIDC + first app password, reusing the admin route surface
- [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12) - [x] **Phase 13: Real Lint Gate (ESLint)** - Wire ESLint flat config (typescript-eslint + React) across both apps so the Phase 8 CI lint slot actually fails on violations instead of no-op'ing (completed 2026-06-12)
@@ -185,7 +185,7 @@ Plans:
**Wave 4** *(blocked on Wave 3 completion)* **Wave 4** *(blocked on Wave 3 completion)*
- [ ] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified) - [x] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
**UI hint**: yes **UI hint**: yes
@@ -369,7 +369,7 @@ Plans:
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 | | 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 | | 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
| 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 | | 9. Faster Write-Back | v1.1 | 2/2 | Complete | 2026-06-12 |
| 10. Admin Role & Settings | v1.1 | 3/4 | In Progress| | | 10. Admin Role & Settings | v1.1 | 4/4 | Complete | 2026-06-13 |
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - | | 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - | | 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 | | 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
@@ -383,7 +383,7 @@ Plans:
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern. **Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD **Requirements:** TBD
**Plans:** 2/4 plans executed **Plans:** 4/4 plans complete
Plans: Plans:
+12 -8
View File
@@ -2,16 +2,16 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.1 milestone: v1.1
milestone_name: Operability & Polish milestone_name: Operability & Polish
status: executing status: verifying
stopped_at: "Completed Phase 10 Plan 03 (admin API surface: requireAdmin-gated adminRouter, shared validateEncryptAndStoreCredential, self-service /api/me/credential)" stopped_at: "Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)"
last_updated: "2026-06-13T18:57:06.660Z" last_updated: "2026-06-13T19:27:40.449Z"
last_activity: 2026-06-13 last_activity: 2026-06-13
progress: progress:
total_phases: 20 total_phases: 20
completed_phases: 7 completed_phases: 8
total_plans: 27 total_plans: 27
completed_plans: 26 completed_plans: 27
percent: 35 percent: 40
--- ---
# Project State # Project State
@@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-06-10)
Phase: 10 (admin-role-settings) — EXECUTING Phase: 10 (admin-role-settings) — EXECUTING
Plan: 4 of 4 Plan: 4 of 4
Status: Ready to execute Status: Phase complete — ready for verification
Last activity: 2026-06-13 Last activity: 2026-06-13
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
@@ -103,6 +103,7 @@ _Updated after each plan completion_
| Phase 10-admin-role-settings P01 | 265 | - tasks | - files | | Phase 10-admin-role-settings P01 | 265 | - tasks | - files |
| Phase 10-admin-role-settings P02 | 700 | 3 tasks | 6 files | | Phase 10-admin-role-settings P02 | 700 | 3 tasks | 6 files |
| Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files | | Phase 10-admin-role-settings P03 | 720 | 3 tasks | 6 files |
| Phase 10-admin-role-settings P04 | 1315 | 3 tasks | 8 files |
## Accumulated Context ## Accumulated Context
@@ -174,6 +175,9 @@ Recent decisions affecting current work:
- [Phase ?]: D-14-gate-security: security wired into gate with individual needs.security.result==success check (not success-or-skipped, Gitea #31007) - [Phase ?]: D-14-gate-security: security wired into gate with individual needs.security.result==success check (not success-or-skipped, Gitea #31007)
- [Phase ?]: D-MIGRATION-10-01: v1.1 DB migration applied via direct mysql2 DDL (drizzle-kit migrate silently failed due to journal hash mismatch with legacy tracking; hash recorded in __drizzle_migrations for forward compatibility) - [Phase ?]: D-MIGRATION-10-01: v1.1 DB migration applied via direct mysql2 DDL (drizzle-kit migrate silently failed due to journal hash mismatch with legacy tracking; hash recorded in __drizzle_migrations for forward compatibility)
- [Phase ?]: D-10-02-aggregate-limit1: Drizzle COUNT aggregate uses .limit(1) for mock-chain compatibility - [Phase ?]: D-10-02-aggregate-limit1: Drizzle COUNT aggregate uses .limit(1) for mock-chain compatibility
- [Phase ?]: isAdmin drives nav visibility; real boundary is server-side
- [Phase ?]: Single bottom sheet component handles all credential entry flows
- [Phase ?]: No X button on SetupBanner; cleared by needsProviderSetup=false from /api/me refetch
### Roadmap Evolution ### Roadmap Evolution
@@ -240,7 +244,7 @@ Recent decisions affecting current work:
## Session Continuity ## Session Continuity
Last session: 2026-06-13T18:57:06.634Z Last session: 2026-06-13T19:27:40.437Z
Stopped at: Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup) Stopped at: Completed Phase 10 Plan 02 (admin role primitives: requireAdmin, upsertUser is_admin, /api/me isAdmin+needsProviderSetup)
Resume file: None Resume file: None
@@ -0,0 +1,200 @@
---
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