--- phase: 10-admin-role-settings plan: 04 type: execute wave: 4 depends_on: ["10-02", "10-03"] files_modified: - apps/pwa/src/api/client.ts - apps/pwa/src/routes/AdminPage.tsx - apps/pwa/src/components/CredentialSheet.tsx - apps/pwa/src/components/SetupBanner.tsx - apps/pwa/src/components/AppNav.tsx - apps/pwa/src/components/BottomTabBar.tsx - apps/pwa/src/App.tsx - apps/pwa/e2e/admin.spec.ts autonomous: true requirements: [ADMIN-01, ADMIN-02, ADMIN-03] must_haves: truths: - "An admin sees an Admin nav entry, reaches /admin, can list members + credential status, rotate a member credential via the sheet, and pick the shared calendar" - "A non-admin never sees the Admin nav entry and is redirected from /admin to /calendar" - "A member with needsProviderSetup=true sees the self-service SetupBanner and can add their own credential via the same sheet (member-scoped)" - "After a successful credential save the SetupBanner clears (needsProviderSetup→false via ['me'] invalidation), there being no dismiss button" artifacts: - path: "apps/pwa/src/routes/AdminPage.tsx" provides: "/admin page: Members section + Shared-Calendar picker, wired to /api/admin/*" min_lines: 60 - path: "apps/pwa/src/components/CredentialSheet.tsx" provides: "shared credential bottom sheet (admin rotation + self-service), password never pre-filled, CalDAV validation states" min_lines: 50 - path: "apps/pwa/src/components/SetupBanner.tsx" provides: "needsProviderSetup self-service onboarding banner (no dismiss; clears on save)" min_lines: 20 - path: "apps/pwa/src/api/client.ts" provides: "MeUser.isAdmin + needsProviderSetup; fetchAdminMembers/saveCredential/fetchAdminCalendars/setSharedCalendar/saveMyCredential" contains: "isAdmin" key_links: - from: "apps/pwa/src/App.tsx" to: "AdminPage / Navigate redirect" via: "meQuery.data.user.isAdmin gate on the /admin Route" pattern: "isAdmin" - from: "apps/pwa/src/components/AppNav.tsx" to: "/admin NavLink" via: "conditional render on isAdmin (ShieldCheck icon)" pattern: "ShieldCheck" - from: "apps/pwa/src/components/CredentialSheet.tsx" to: "/api/admin/credentials | /api/me/credential" via: "TanStack mutation, invalidates ['admin','members'] + ['me']" pattern: "invalidateQueries" --- Build the React PWA admin surfaces per the 10-UI-SPEC contract: extend the `/api/me` client type + add admin/self-service fetchers, add the gated `/admin` route (D-02: dedicated gated route, not an extension of SettingsSheet) + conditional nav entries, build the shared CredentialSheet (admin rotation + member self-service) and the needsProviderSetup SetupBanner. Verify the route guard and nav gating in a real Chromium browser with playwright-cli (the desktop-Chromium-drivable behaviors), not a human checkpoint. Purpose: This is the user-facing half of ADMIN-01/02/03 + the D-07 self-service onboarding. The client `isAdmin` flag is consumed for UX gating only per D-03 (server already enforces 403 on every /api/admin/* route from Plan 03); `needsProviderSetup` drives the self-service banner. Output: Extended client.ts, AdminPage.tsx, CredentialSheet.tsx, SetupBanner.tsx, nav-entry edits, the /admin route in App.tsx, and an e2e spec. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/10-admin-role-settings/10-CONTEXT.md @.planning/phases/10-admin-role-settings/10-UI-SPEC.md @.planning/phases/10-admin-role-settings/10-PATTERNS.md @.planning/phases/10-admin-role-settings/10-02-SUMMARY.md @.planning/phases/10-admin-role-settings/10-03-SUMMARY.md Task 1: Extend client.ts — MeUser fields + admin/self-service fetchers apps/pwa/src/api/client.ts - apps/pwa/src/api/client.ts (the file being modified — `MeUser` interface lines 62–66, `handleAuthResponse` lines 51–58, the `createEvent` fetch pattern lines 221–233 with `credentials:'include'`, `redirect:'manual'`, `SessionExpiredError` handling) - .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/pwa/src/api/client.ts` (the MeUser field additions + the fetch-function pattern to copy from createEvent for each admin/me endpoint) + §Shared Patterns "handleAuthResponse + redirect:'manual'" - .planning/phases/10-admin-role-settings/10-03-SUMMARY.md (the exact /api/admin/* + /api/me/credential request/response shapes shipped by Plan 03 — match them) - .planning/phases/10-admin-role-settings/10-UI-SPEC.md Surface 2 (member-row fields) + Surface 5 (calendar-picker fields) In `apps/pwa/src/api/client.ts`: (1) add `isAdmin: boolean` and `needsProviderSetup: boolean` to the `MeUser` interface (D-03: client consumes isAdmin for UX gating only). (2) Add typed fetch functions matching the Plan-03 contracts, each following the `createEvent` idiom (`credentials:'include'`, `redirect:'manual'`, `handleAuthResponse(res, label)`): `fetchAdminMembers()` → GET /api/admin/members; `saveCredential(payload)` → POST /api/admin/credentials (admin, includes userId); `fetchAdminCalendars()` → GET /api/admin/calendars; `setSharedCalendar(calendarId)` → PUT /api/admin/calendars/:id/shared; `saveMyCredential(payload)` → POST /api/me/credential (self-service, NO userId). Define request/response TS types matching the Plan-03 SUMMARY shapes. NEVER store or log the app password client-side beyond the in-flight request body. cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit 2>&1 | tail -8 - `MeUser` includes `isAdmin: boolean` and `needsProviderSetup: boolean` (`grep -c "isAdmin\|needsProviderSetup" apps/pwa/src/api/client.ts` >= 2). - All five fetchers exist and use `credentials:'include'` + `redirect:'manual'` + `handleAuthResponse`. - `saveMyCredential`'s payload type has NO `userId` field; `saveCredential`'s does. - `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0. client.ts exposes the new MeUser flags + five typed admin/self-service fetchers, typechecks clean. Task 2: CredentialSheet + SetupBanner components apps/pwa/src/components/CredentialSheet.tsx, apps/pwa/src/components/SetupBanner.tsx - apps/pwa/src/components/SettingsSheet.tsx (EXACT analog for CredentialSheet — the bottom-sheet pattern: role="dialog", aria-modal, Escape handler lines 52–76, focus-on-open, backdrop zIndex 300 / sheet zIndex 301 / borderRadius 12px 12px 0 0 / padding var(--space-6), focus-return-to-trigger) - apps/pwa/src/components/PermissionDeniedBanner.tsx (analog for SetupBanner — conditional banner rendered at App level, role/aria-live pattern) - apps/pwa/src/components/DeleteConfirmationDialog.tsx (the minHeight:48px confirm-button precedent referenced in UI-SPEC) - apps/pwa/src/api/client.ts (saveCredential + saveMyCredential from Task 1; SessionExpiredError) - .planning/phases/10-admin-role-settings/10-UI-SPEC.md Surface 3 (credential sheet: heading variants, member subtitle, password field type="password"/autocomplete="new-password"/never pre-filled, helper text + Fastmail app-password link target="_blank" rel="noopener noreferrer", "Validating against CalDAV…" Loader2 spinner, failure copy, Save/Cancel actions) + Surface 4 (self-service banner: KeyRound icon, copy, "Set up now" CTA, no X/dismiss) + the Copywriting Contract (exact strings) + Accessibility Contracts + Color/Typography/Spacing (all via var(--token), 44px touch targets) - .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/pwa/src/components/CredentialSheet.tsx` + §`apps/pwa/src/components/SetupBanner.tsx` (structural + mutation + style excerpts) + §Shared Patterns "CSS token inline style pattern" - SetupBanner dismissal (success-only): there is NO dismiss/X button per UI-SPEC Surface 4 — the ONLY way the banner clears is a successful credential save. After a successful save the CredentialSheet mutation's `onSuccess` invalidates the `['me']` query → /api/me refetches → `needsProviderSetup` becomes `false` → SetupBanner unmounts on the next rerender. A test/behavior assertion: given `needsProviderSetup=true` the banner renders; after a successful save (mocked) that flips /api/me to `needsProviderSetup=false`, the banner is no longer in the DOM. (No interaction other than success clears it.) Build `CredentialSheet.tsx` (shared by admin rotation AND self-service per D-07) following the SettingsSheet bottom-sheet pattern: props for mode (admin-rotate / admin-add / self-service), target member (admin) or current user (self-service), open/close. Render the heading variant per UI-SPEC Copywriting Contract ("Rotate Credential" / "Add Credential" / "Add your calendar credential"), the member-name subtitle, a `type="password" autoComplete="new-password"` field NEVER pre-filled, the helper text with the Fastmail app-password link (exact URL + copy from UI-SPEC, opens in new tab), the "Validating against CalDAV…" inline state (Loader2 size 16) during the mutation, the failure error copy on a CalDAV 400, and Cancel (ghost) + Save Credential (accent-filled) actions. Use a TanStack `useMutation` that calls `saveCredential` (admin) or `saveMyCredential` (self-service) and on success invalidates `['admin','members']` + `['me']` (so needsProviderSetup refreshes and the SetupBanner clears) and closes the sheet. Build `SetupBanner.tsx` following PermissionDeniedBanner: render only when `meQuery.data?.user.needsProviderSetup === true`, `role="status" aria-live="polite"`, KeyRound icon, the exact heading/body/CTA copy, "Set up now" opening the CredentialSheet in self-service mode; NO dismiss button (it clears ONLY when needsProviderSetup becomes false after a successful save — the success-only dismissal behavior above). All styling via `var(--token)`; every interactive element minWidth/minHeight 44px. Never log/echo the password. cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit 2>&1 | tail -8 && grep -l "autocomplete=\"new-password\"\|autoComplete=\"new-password\"" apps/pwa/src/components/CredentialSheet.tsx - `CredentialSheet.tsx` uses `role="dialog"`, `aria-modal`, an Escape handler, and a `type="password"` input with `autoComplete="new-password"` that is never pre-filled (no `value={existingPassword}` from any fetched source). - The helper text contains the Fastmail app-password link with `target="_blank"` and `rel="noopener noreferrer"`. - The success mutation invalidates both `['admin','members']` and `['me']` (`grep -c "invalidateQueries" apps/pwa/src/components/CredentialSheet.tsx` >= 2). - `SetupBanner.tsx` renders conditionally on `needsProviderSetup`, uses `role="status"`/`aria-live`, and has NO dismiss/X button. - SetupBanner success-only dismissal holds: when /api/me reports `needsProviderSetup=false` (the state after a successful save invalidates `['me']`), the banner does not render — there is no code path that hides it other than the `needsProviderSetup` flag flipping to false. - Exact UI-SPEC Copywriting Contract strings are present (e.g. "Set up your calendar", "Validating against CalDAV…", "Save Credential"). - No hard-coded color/spacing px except the 44px/48px touch-target minimums; values reference `var(--...)`. - `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0. CredentialSheet (admin + self-service) and SetupBanner match the UI-SPEC contract, accessible, token-styled, success-only banner dismissal wired via ['me'] invalidation, typecheck clean. Task 3: /admin route + AdminPage + conditional nav entries, with playwright-cli verification apps/pwa/src/routes/AdminPage.tsx, apps/pwa/src/App.tsx, apps/pwa/src/components/AppNav.tsx, apps/pwa/src/components/BottomTabBar.tsx, apps/pwa/e2e/admin.spec.ts - apps/pwa/src/App.tsx (the Routes block lines 121–127, the meQuery lines 63–68, the content-area style lines 99–102, where PermissionDeniedBanner-style banners mount — SetupBanner mounts here too) - apps/pwa/src/routes/ListsIndex.tsx (page-level component analog for AdminPage: TanStack Query + sections layout) - apps/pwa/src/components/AppNav.tsx (NavLink + Lucide pattern lines 14–15, the DesktopNav section) + apps/pwa/src/components/BottomTabBar.tsx (tab pattern lines 27–98) — add the conditional Admin entry (ShieldCheck) to both - apps/pwa/e2e/calendar.spec.ts + layout.spec.ts + e2e/README.md (existing spec idioms, selectors, the dev-bypass admin user seeded in global-setup as id=1 is_admin=true from Plan 01) - .claude/skills/playwright-cli/SKILL.md (how to drive the global playwright-cli binary for the supplementary browser verification) - .planning/phases/10-admin-role-settings/10-UI-SPEC.md Surface 1 (/admin page: AppNav persistent, ShieldCheck size 18, content maxWidth 640px centered desktop, var(--space-12) vertical padding, "Admin Settings" heading) + Surface 2 (Members section) + Surface 5 (Shared Calendar picker: radio group, "Currently shared" label, two-tap Save, empty state) + Copywriting Contract - .planning/phases/10-admin-role-settings/10-PATTERNS.md §`apps/pwa/src/routes/AdminPage.tsx` + §`apps/pwa/src/App.tsx` + §Shared Patterns "NavLink + Lucide icon" Build `AdminPage.tsx` (analog ListsIndex): "Admin Settings" heading (18px/600), a MEMBERS section listing members from `fetchAdminMembers` (avatar swatch + name + credential status badge per UI-SPEC Surface 2 + a "Rotate"/"Add credential" button opening CredentialSheet in admin mode for that member), and a SHARED CALENDAR section (Surface 5) using `fetchAdminCalendars`: an exclusive single-select radio group (D-06), the "Currently shared" label on the active one, a two-tap "Save" button (disabled until selection differs) calling `setSharedCalendar`, and the empty state ("No calendars synced yet") when none synced. Desktop: centered column maxWidth 640px. In `App.tsx`: add the `/admin` Route (D-02: new dedicated gated route) gated by `meQuery.data?.user.isAdmin ? : ` — the client redirect is UX-only per D-03 (the server-side 403 from Plan 03 is the real boundary); add a loading gate so an in-flight meQuery doesn't flash-redirect (planner's call per 10-PATTERNS.md note); mount `` above the calendar content (renders only on needsProviderSetup). In `AppNav.tsx` (desktop) and `BottomTabBar.tsx` (mobile): add an Admin entry (ShieldCheck icon, `aria-label="Admin settings"`) rendered ONLY when `meQuery.data?.user.isAdmin === true` (D-03 UX gating). Verification has two layers, kept distinct: 1. AUTOMATED GATE (the verify command): write `apps/pwa/e2e/admin.spec.ts` and run `pnpm --filter @familysync/pwa test:e2e -- admin`. With the dev-bypass admin user (seeded id=1 is_admin=true), assert the Admin nav entry is visible and /admin renders "Admin Settings" + the Members section. Add a non-admin assertion by route-mocking GET /api/me to `isAdmin:false` (per [[dev-data-user1-no-calendars]] route-mock idiom and the lists.spec page.route precedent) and asserting the Admin nav entry is absent and /admin redirects to /calendar. THIS e2e SPEC IS THE GATE. 2. SUPPLEMENTARY (not the gate): run an interactive `playwright-cli` check against the running dev stack at the `/admin` route to confirm the guard redirect + nav gating + CredentialSheet opens — drive the global `/usr/local/bin/playwright-cli` binary per `.claude/skills/playwright-cli/SKILL.md` (navigate to /admin as the dev-bypass admin, confirm "Admin Settings" + open the credential sheet; then with a route-mocked non-admin confirm the redirect to /calendar). Record the playwright-cli observations in the SUMMARY. This is an optional supplementary confirmation; if the dev stack is not up it does not block the plan — the e2e spec is the binding proof. All styling via `var(--token)`; 44px touch targets. cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit 2>&1 | tail -6 && pnpm --filter @familysync/pwa test:e2e -- admin 2>&1 | tail -20 - `apps/pwa/src/App.tsx` has a `/admin` Route gated on `meQuery.data?.user.isAdmin` with a `` fallback for non-admins, and mounts ``. - `AppNav.tsx` and `BottomTabBar.tsx` render the Admin entry (ShieldCheck, `aria-label="Admin settings"`) ONLY when isAdmin is true (`grep -c "ShieldCheck" apps/pwa/src/components/AppNav.tsx apps/pwa/src/components/BottomTabBar.tsx` >= 2). - `AdminPage.tsx` renders "Admin Settings", a MEMBERS list, and a SHARED CALENDAR exclusive radio group with a two-tap Save and an empty state. - `apps/pwa/e2e/admin.spec.ts` asserts: admin sees the nav entry + reaches /admin; a non-admin (route-mocked isAdmin:false) does NOT see it and /admin redirects to /calendar. - The e2e spec (`pnpm --filter @familysync/pwa test:e2e -- admin`) passes and IS the gate; the playwright-cli interactive check is a supplementary confirmation (guard + nav gating + sheet open) recorded in the SUMMARY, not the binding proof. - `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0. /admin route gated, AdminPage wired to the admin API, conditional nav entries + SetupBanner mounted, e2e gate green; playwright-cli supplementary check recorded in the SUMMARY. New symbols/files created by this plan (excluded from drift verification): - `MeUser.isAdmin` + `MeUser.needsProviderSetup` fields in `apps/pwa/src/api/client.ts` - fetchers `fetchAdminMembers`, `saveCredential`, `fetchAdminCalendars`, `setSharedCalendar`, `saveMyCredential` - `apps/pwa/src/routes/AdminPage.tsx` - `apps/pwa/src/components/CredentialSheet.tsx` - `apps/pwa/src/components/SetupBanner.tsx` - `/admin` Route + isAdmin gate + `` mount in `apps/pwa/src/App.tsx` - conditional Admin nav entry (ShieldCheck) in `apps/pwa/src/components/AppNav.tsx` + `apps/pwa/src/components/BottomTabBar.tsx` - `apps/pwa/e2e/admin.spec.ts` ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser isAdmin flag → UI gating | the client isAdmin flag controls nav/route visibility only; it is NOT the security boundary | | credential sheet → API | the app password is entered in the browser and sent to /api/admin/credentials or /api/me/credential over the in-flight request only | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-10-14 | Elevation of Privilege | a non-admin bypassing the client route guard (e.g. typing /admin) | accept | The client redirect is UX (D-03); the real boundary is the server-side 403 on every /api/admin/* request (Plan 03). A non-admin who forces /admin sees no data — all admin fetches return 403. e2e asserts the redirect anyway | | T-10-15 | Information Disclosure | app password persisted/echoed client-side | mitigate | Password field never pre-filled, never written to localStorage/state beyond the in-flight mutation; no console.log of the value (acceptance: never pre-filled) | | T-10-16 | Information Disclosure | password autofilled with the current credential | mitigate | autoComplete="new-password" (never "current-password"); the existing credential is never fetched to the client | | T-10-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this phase (lucide-react already at 1.17.0 per RESEARCH Package Legitimacy Audit); no install task | - `pnpm --filter @familysync/pwa exec tsc --noEmit` passes. - `pnpm --filter @familysync/pwa test:e2e -- admin` passes (admin sees nav + /admin; non-admin redirected, no nav entry) — this is the binding gate. - playwright-cli interactive check confirms the guard + nav gating + sheet open (supplementary, recorded in SUMMARY). - Run the full CI fast-checks gate locally before declaring done (lint + typecheck + test + format:check + md:lint + PWA tests) per [[feedback-run-full-ci-gate-before-push]]. - Admin sees the Admin section, lists members + credential status, rotates a credential via the sheet, picks the shared calendar (Success Criteria 1, 2, 3). - Non-admin never sees the entry and is redirected from /admin (Success Criterion 1; client-side UX over the server 403). - needsProviderSetup member sees the SetupBanner and can self-serve their own credential; the banner clears on a successful save (no dismiss button) (D-07). Create `.planning/phases/10-admin-role-settings/10-04-SUMMARY.md` when done.