diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e9c755e..5d620bc 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -762,7 +762,11 @@ Plans: **Plans:** 3 plans Plans: +**Wave 1** - [ ] 20-01-PLAN.md — Server: PATCH /api/admin/members/:id (displayName + is_admin) with last-admin demotion guard (TDD) + isAdmin in GET /members - [ ] 20-02-PLAN.md — PWA API client: AdminMember.isAdmin field + updateMemberProfile fetcher (last-admin sentinel) + +**Wave 2** *(blocked on Wave 1 completion)* + - [ ] 20-03-PLAN.md — PWA: unified MemberEditorSheet (edit/create, per-section saves) + decluttered tappable Members panel; retire Rotate/Reset-password buttons diff --git a/.planning/STATE.md b/.planning/STATE.md index 76e94fb..3608f51 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,8 +5,8 @@ milestone_name: Operability & Polish current_phase: 18 current_phase_name: Auto timezone detection and ability to change timezone status: executing -stopped_at: Phase 20 context gathered -last_updated: "2026-06-18T20:42:23.831Z" +stopped_at: Phase 20 UI-SPEC approved +last_updated: "2026-06-18T21:06:24.292Z" last_activity: 2026-06-18 last_activity_desc: Phase 17 complete, transitioned to Phase 18 progress: @@ -30,7 +30,7 @@ See: .planning/PROJECT.md (updated 2026-06-16) Phase: 18 — Auto timezone detection and ability to change timezone Plan: Not started -Status: Executing Phase 17 +Status: Ready to execute Last activity: 2026-06-18 — Phase 17 complete, transitioned to Phase 18 ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -274,9 +274,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-18T20:42:23.817Z -Stopped at: Phase 20 context gathered -Resume file: .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md +Last session: 2026-06-18T20:51:46.755Z +Stopped at: Phase 20 UI-SPEC approved +Resume file: .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md ## Operator Next Steps diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md b/.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md new file mode 100644 index 0000000..e2e2dc8 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-PATTERNS.md @@ -0,0 +1,276 @@ +# Phase 20: Admin Member Editor & Form Declutter - Pattern Map + +**Mapped:** 2026-06-18 +**Files analyzed:** 5 (2 new surfaces, 3 modified) +**Analogs found:** 5 / 5 (all in-repo, recent) + +> No RESEARCH.md for this phase — this is a client-side rework over existing `/api/admin` endpoints. Every new file copies a concrete in-repo analog; no external pattern is needed. + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `apps/pwa/src/components/MemberEditorSheet.tsx` (NEW) | component (dialog/sheet) | request-response (form → mutation) | `apps/pwa/src/components/CredentialSheet.tsx` + `ResetPasswordSheet` (in `AdminPage.tsx`) | exact | +| `apps/api/src/routes/admin.ts` — new `PATCH /members/:id` (NEW route, MODIFIED file) | route (member-profile update) | CRUD (update) | `admin.ts` `POST /members/:id/password` (`:225`) + last-admin count in `auth/user.ts:151` | exact | +| `apps/pwa/src/routes/AdminPage.tsx` (MODIFIED) | route/page (MemberRow + triggers) | request-response | `ListCard.tsx` (tappable row + ChevronRight) + existing `MemberRow` (`:1151`) | exact | +| `apps/pwa/src/api/client.ts` (MODIFIED) | api client (type + fetcher) | request-response | `fetchAdminResetPassword` (`:218`) / `fetchCreateMember` (`:184`) | exact | +| `apps/api/src/routes/admin.ts` — `GET /members` adds `isAdmin` (MODIFIED) | route | CRUD (read) | `admin.ts` `GET /members` (`:102`) | exact (same handler) | + +--- + +## Pattern Assignments + +### `apps/pwa/src/components/MemberEditorSheet.tsx` (component, dialog/sheet) + +**Primary analog:** `apps/pwa/src/components/CredentialSheet.tsx` +**Secondary analog:** `ResetPasswordSheet` inside `apps/pwa/src/routes/AdminPage.tsx:1391` (password+confirm+mismatch logic to fold into the "Set new password" section per D-06). + +This is the central new file. Copy the **entire dialog scaffold** from CredentialSheet, then compose the three edit-mode sections (Profile / Set new password / App password) + the create-mode form from existing field/mutation snippets. + +**Imports pattern** (`CredentialSheet.tsx:25-35`): +```typescript +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { + saveCredential, + type SaveCredentialPayload, +} from '../api/client.js'; +import { useIsPhone } from '../hooks/useIsPhone.js'; +import { useFocusTrap } from '../hooks/useFocusTrap.js'; +``` +For Phase 20 also import the new `updateMemberProfile` fetcher + existing `fetchAdminResetPassword`, and add `ChevronRight`/`Plus` are NOT needed here (those go in AdminPage). + +**`mode` prop pattern** — model the create/edit discriminator on `CredentialSheetMode` (`CredentialSheet.tsx:37`). UI-SPEC §Surface B wants a single `mode: 'edit' | 'create'` prop (id present = edit). Mirror the `headingFor(mode)` switch (`CredentialSheet.tsx:54-58`) for "Edit member" / "Add member" copy. + +**Dialog scaffold — copy verbatim** (`CredentialSheet.tsx`): +- `useFocusTrap(dialogRef)` wiring (`:88-89`) + `onKeyDown={handleDialogKeyDown}` on the dialog div (`:186`) +- `handleClose` via `useCallback` that clears form state, calls `onClose()`, and returns focus to `triggerRef.current` (`:94-103`) +- Escape-to-close effect (`:106-115`) +- Focus-heading-on-open effect (`:118-122`) +- Backdrop div (`:169-178`) — note UI-SPEC §Surface B wants `rgba(0,0,0,0.32)` / `--color-overlay`, which matches `ResetPasswordSheet:1459` (`var(--color-overlay, rgba(0,0,0,0.32))`), NOT CredentialSheet's `rgba(0,0,0,0.4)`. Prefer the ResetPasswordSheet overlay token. +- Phone-vs-desktop sheet style object (`:187-217`) — copy exactly (borderRadius `12px 12px 0 0` phone / `12px` desktop, `maxWidth 480px`, zIndex 301, `padding var(--space-6)`, desktop `maxHeight: calc(100dvh - var(--space-8)); overflowY: auto`) +- `h2 ref={headingRef} tabIndex={-1}` heading (`:220-233`) +- Member subtitle block (`:236-247`) — edit mode only + +**Section divider** (UI-SPEC §Surface B): `border-top: 1px solid var(--color-border-subtle)`, `margin: var(--space-6) 0`. Section headings reuse `sectionLabelStyle` from `AdminPage.tsx:46-53` (13px/600/uppercase/`--color-text-muted`) — UI-SPEC Accessibility note says use `
` not `

` to avoid heading-hierarchy issues under the `

`. + +**Text input field pattern** (`CredentialSheet.tsx:250-283` email field; `AdminPage.tsx:477-495` display-name field) — label (13px/600) + input (`min-height 44px`, `border-radius var(--space-1)`, `padding var(--space-3) var(--space-4)`, `border` flips to `--color-destructive` on error). + +**Password section (Section 2)** — copy `ResetPasswordSheet`'s new-password + confirm fields and mismatch logic: +```typescript +// AdminPage.tsx:1426-1443 — mutation with client-side mismatch guard +const resetMutation = useMutation({ + mutationFn: async () => { + if (newPassword !== confirmPassword) throw new Error('mismatch'); + await fetchAdminResetPassword(member.id, newPassword); + }, + onSuccess: () => { handleClose(); onSuccess?.(); }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'server'; + if (msg === 'mismatch') setError('Passwords do not match.'); + else setError('Something went wrong. Please try again.'); + }, +}); +``` +UI-SPEC adds a `< 8 chars` guard ("Password must be at least 8 characters.") — mirror the create-member length check at `AdminPage.tsx:267-269`. Fields use `autoComplete="new-password"`, never prefilled (T-10-15/16). **Per-section** save (not handleClose-on-success) — Section 2 success keeps the sheet open and fires toast "Password updated." Only render this section when `member.hasLocalCredential === true`. + +**App-password section (Section 3)** — copy CredentialSheet's email + password fields (`:249-320`), the "Validating against CalDAV…" `Loader2` inline state (`:337-345`), the helper link to Fastmail device tokens (`:351-362`), and the FAILURE_TEXT copy (`:65-66`). The mutation maps to `saveCredential` (`:124-153`) — admin mode requires `userId: memberId`. Prefill `fastmailEmail` read-only convenience in edit mode (Claude's-discretion D — the stored `fastmailEmail` exists on `memberCredentials.fastmail_email`, but is NOT currently returned by `GET /members`; the password field is never prefilled). + +**Create-mode form (single form, no dividers)** — copy the four fields + mutation from `AdminPage.tsx:260-306` (`createMemberMutation`): display name / username / initial password / confirm. Maps to `fetchCreateMember` (`client.ts:184`). Reuse the exact error mapping (`mismatch` / `short` / `conflict` → "That username is already in use."). + +**Per-section Save button** (`CredentialSheet.tsx:397-419`): accent `--color-member-0` background when enabled, `--color-border` when disabled, white text, `min-height 44px`, `border-radius var(--space-1)`, inline `Loader2 size={14}` while pending (pattern at `AdminPage.tsx:651-657`). + +**Cancel button** (`CredentialSheet.tsx:375-395`): no background, `--color-text-secondary`, `min-height 44px`. + +--- + +### `apps/api/src/routes/admin.ts` — NEW `PATCH /members/:id` (route, CRUD update) + +**Primary analog:** `POST /members/:id/password` (`admin.ts:225-269`) — same `:id` param shape, same `requireAdmin` boundary, same noEchoHook posture, same existence-check-then-update flow. +**Secondary analog:** the admin-count query in `auth/user.ts:151-156` — reuse for the D-03 last-admin guard. + +**Route handler shape — copy** (`admin.ts:225-269`): +```typescript +const updateMemberSchema = z.object({ + displayName: z.string().min(1).max(256).optional(), + isAdmin: z.boolean().optional(), +}); + +adminRouter.patch( + '/members/:id', + zValidator('json', updateMemberSchema, noEchoHook), + async (c) => { + const targetId = parsePositiveIntParam(c.req.param('id')); // :87 + if (targetId === null) return c.json({ error: 'Invalid member id' }, 400); + const { displayName, isAdmin } = c.req.valid('json'); + // ... existence check + last-admin guard + update + }, +); +``` +- `parsePositiveIntParam` already exists (`admin.ts:87-92`) — reuse, do NOT re-implement. +- `requireAdmin` is already mounted router-wide (`admin.ts:47`) — the new route inherits it automatically; **no new boundary** (D-02). +- `noEchoHook` (`admin.ts:75-79`) — apply even though no password is in this body, for consistency with the other admin write routes. + +**Last-admin guard (D-03) — adapt from `auth/user.ts:151-156`:** +```typescript +// Count remaining admins; reject demotion of the only admin. +const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)) + .limit(1); +``` +When `isAdmin === false` is requested for a target that is currently an admin AND `Number(count) <= 1`, return 409 (or 422) `{ error: ... }`. UI-SPEC client maps this to "Cannot remove admin — at least one admin must remain." Note: `sql` and `eq` are already imported (`admin.ts:28`). + +**Update + error handling** — mirror the try/catch + 503 fallback of the password route (`admin.ts:249-268`). Build a partial `set({ ... })` from whichever of `displayName` / `isAdmin` is present. Verify the target user exists (404 if not), matching the password route's `if (!credRow) return 404` shape (`:245`). + +**Verb choice (Claude's discretion, D-02):** Hono supports `adminRouter.patch(...)`. The repo's existing admin writes use `POST` (`/members`, `/credentials`) and `PUT` (`/calendars/:id/shared`, `/config/timezone`); a `PATCH` for partial member update is idiomatic and consistent with REST, but `POST /members/:id` is equally acceptable — planner's call. + +--- + +### `apps/api/src/routes/admin.ts` — `GET /members` adds `isAdmin` (route, CRUD read) + +**Analog:** the existing handler itself (`admin.ts:102-124`). Add `isAdmin` to the select and the mapped object: +```typescript +.select({ + id: users.id, + displayName: users.displayName, + color: users.color, + isAdmin: users.isAdmin, // NEW — feeds the editor toggle initial state (D-02) + credentialId: memberCredentials.id, + localCredId: localCredentials.id, +}) +// ... +const members = rows.map((row) => ({ + id: row.id, + displayName: row.displayName, + color: row.color, + isAdmin: row.isAdmin, // NEW + hasCredential: row.credentialId !== null, + hasLocalCredential: row.localCredId !== null, +})); +``` +`users.isAdmin` already exists in the schema (`db/schema.ts:56`, `boolean('is_admin')`). No join change needed — it's a column on the base `users` table already in the FROM. + +--- + +### `apps/pwa/src/api/client.ts` (api client — type + fetcher) + +**Analog for the type:** `AdminMember` interface (`client.ts:563-569`). Add `isAdmin: boolean`: +```typescript +export interface AdminMember { + id: number; + displayName: string | null; + color: string; + isAdmin: boolean; // NEW — Phase 20 (drives editor admin toggle initial state) + hasCredential: boolean; + hasLocalCredential: boolean; +} +``` + +**Analog for the new fetcher:** `fetchAdminResetPassword` (`client.ts:218-234`) — same `:id` path, same POST/PATCH-with-json shape, same error handling. New `updateMemberProfile`: +```typescript +export async function updateMemberProfile( + memberId: number, + body: { displayName?: string; isAdmin?: boolean }, +): Promise { + const res = await fetch(`/api/admin/members/${memberId}`, { + method: 'PATCH', // match the chosen route verb + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + redirect: 'manual', + body: JSON.stringify(body), + }); + if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); + if (res.status === 409 || res.status === 422) throw new Error('last-admin'); // D-03 guard + if (!res.ok) throw new Error(`updateMemberProfile failed: ${res.status}`); +} +``` +The `SessionExpiredError` + `handleAuthResponse` conventions are already in this file (used by every fetcher). Map the last-admin 409/422 to a sentinel the editor's onError can branch on (mirrors the `conflict` sentinel pattern at `client.ts:206`). + +--- + +### `apps/pwa/src/routes/AdminPage.tsx` (route/page — MemberRow rework + triggers) + +**MemberRow → tappable row analog:** `apps/pwa/src/components/ListCard.tsx` — a whole-row `