Files
familysync/.planning/phases/10-admin-role-settings/10-04-PLAN.md
T

219 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="execute">
<name>Task 1: Extend client.ts — MeUser fields + admin/self-service fetchers</name>
<files>apps/pwa/src/api/client.ts</files>
<read_first>
- apps/pwa/src/api/client.ts (the file being modified — `MeUser` interface lines 6266, `handleAuthResponse` lines 5158, the `createEvent` fetch pattern lines 221233 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/pwa exec tsc --noEmit 2>&1 | tail -8</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>client.ts exposes the new MeUser flags + five typed admin/self-service fetchers, typechecks clean.</done>
</task>
<task type="execute">
<name>Task 2: CredentialSheet + SetupBanner components</name>
<files>apps/pwa/src/components/CredentialSheet.tsx, apps/pwa/src/components/SetupBanner.tsx</files>
<read_first>
- apps/pwa/src/components/SettingsSheet.tsx (EXACT analog for CredentialSheet — the bottom-sheet pattern: role="dialog", aria-modal, Escape handler lines 5276, 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"
</read_first>
<behavior>
- 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.)
</behavior>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>CredentialSheet (admin + self-service) and SetupBanner match the UI-SPEC contract, accessible, token-styled, success-only banner dismissal wired via ['me'] invalidation, typecheck clean.</done>
</task>
<task type="execute">
<name>Task 3: /admin route + AdminPage + conditional nav entries, with playwright-cli verification</name>
<files>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</files>
<read_first>
- apps/pwa/src/App.tsx (the Routes block lines 121127, the meQuery lines 6368, the content-area style lines 99102, 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 1415, the DesktopNav section) + apps/pwa/src/components/BottomTabBar.tsx (tab pattern lines 2798) — 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"
</read_first>
<action>
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 ? <AdminPage/> : <Navigate to="/calendar" replace/>` — 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 `<SetupBanner/>` 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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `apps/pwa/src/App.tsx` has a `/admin` Route gated on `meQuery.data?.user.isAdmin` with a `<Navigate to="/calendar" replace/>` fallback for non-admins, and mounts `<SetupBanner/>`.
- `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.
</acceptance_criteria>
<done>/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.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
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 + `<SetupBanner/>` 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`
</artifacts_this_phase_produces>
<threat_model>
## 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 |
</threat_model>
<verification>
- `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]].
</verification>
<success_criteria>
- 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).
</success_criteria>
<output>
Create `.planning/phases/10-admin-role-settings/10-04-SUMMARY.md` when done.
</output>
</content>
</invoke>