219 lines
21 KiB
Markdown
219 lines
21 KiB
Markdown
---
|
||
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 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)
|
||
</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 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"
|
||
</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 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"
|
||
</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>
|