From 51ec9c3e821567b1bb0639bebb7061cfffe33e7e Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 15:14:25 -0400 Subject: [PATCH 01/34] =?UTF-8?q?docs(roadmap):=20add=20Phase=2020=20?= =?UTF-8?q?=E2=80=94=20Admin=20Member=20Editor=20&=20Form=20Declutter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 12 ++++++++++++ .planning/STATE.md | 1 + .../20-admin-member-editor-form-declutter/.gitkeep | 1 + 3 files changed, 14 insertions(+) create mode 100644 .planning/phases/20-admin-member-editor-form-declutter/.gitkeep diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 201938f..7d56fea 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -453,6 +453,7 @@ Plans: | 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 | | 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 | | 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 | +| 20. Admin Member Editor & Declutter | v1.1 | — | Not planned | — | ## Backlog @@ -752,3 +753,14 @@ Plans: - [x] 19-04-PLAN.md — PWA: LoginPage + BrandSlot + App.tsx gate + client.ts + AdminPage + SettingsSheet (AUTH-LOCAL-12/13/14/15) - [x] 19-05-PLAN.md — Dev-bypass Option C + break-glass CLI + harness/CI rework + login.spec.ts (AUTH-LOCAL-11/16) + +### Phase 20: Admin Member Editor & Form Declutter + +**Goal:** Replace the per-member-row action buttons (Rotate/Add credential + Reset password) in the admin Members panel with a single edit affordance — clicking a member's name or an edit button opens a member-detail editor where an admin modifies all of that member's details in one place: display name, local-login password, and the Fastmail/CalDAV app password (calendar credential) — using clear, non-jargon labels that retire the confusing "Rotate" term. Also collapse the "Add member" section so its input fields are hidden behind a single "Add member" trigger by default, decluttering the panel. Client-side AdminPage + CredentialSheet rework over the existing `/api/admin` endpoints; no new auth/authorization boundary (seeded by the gripe that "Rotate" for the app password is not intuitive). +**Requirements**: TBD (refine in /gsd-discuss-phase 20 — open scope: which fields count as "all" (color swatch? admin toggle? OIDC link?), whether to keep any standalone reset-password flow, and the exact edit affordance — clickable name vs. row edit button) +**Depends on:** Phase 19 +**Plans:** 0 plans + +Plans: + +- [ ] TBD (run /gsd-plan-phase 20 to break down) diff --git a/.planning/STATE.md b/.planning/STATE.md index a17be3b..bd3a128 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -216,6 +216,7 @@ Recent decisions affecting current work: - **Phase 16 added (2026-06-12, /gsd-phase):** CI Dependency Audit, Security Checks & Image Hygiene — extend the Phase 8 Gitea CI workflow with outdated-dependency reporting (`pnpm outdated`), a vulnerability audit (`pnpm audit` at a chosen severity), and a baseline of additional security checks (secret scan / image scan). User requested a 16 integer phase (not a decimal insert) — they've been running independent/CI phases ahead of the admin chain. **Depends on Phase 8; independent of the admin chain (10–12).** Scope still needs definition — run /gsd-discuss-phase 16. Milestone window now Phases 7–16. - **Backlog 999.17 folded into Phase 16 + removed (2026-06-12, /gsd-phase):** the dev/prod image-boundary item (confine `DEV_AUTH_BYPASS` to dev via build/boot-time enforcement; ensure no `.env`/secrets/encryption keys/`DEV_USER` seed/family data ship in published images; CI assertion in the publish job) was pulled into Phase 16 — shared CI surface and overlapping secret/image scanning made a separate phase redundant. The 999.17 backlog entry + its phase dir were **deleted** (not retained-for-history) since the scope now lives in an active phase; this also clears the recycled-number collision with Phase 15's historical "promoted from 999.17" provenance (the markdown-lint item that became Phase 15 had reused 999.17 first). - **Phase 18 added (2026-06-13, /gsd-phase):** Auto timezone detection and ability to change timezone — let the app auto-detect the household timezone and allow changing it. User invoked `/gsd-phase --insert 18` but Phase 18 didn't exist (17 was the last integer phase), so after confirmation it was added as an integer phase at the end of the milestone, not a decimal insert. Motivated by the Phase 11 all-day-reminder dependency on a correct server `TZ` (all-day reminders fire at 9 AM local, computed from `process.env.TZ`). Scope still needs definition — run /gsd-discuss-phase 18. Milestone window now Phases 7–18. +- **Phase 20 added (2026-06-18, /gsd-phase):** Admin Member Editor & Form Declutter — replace the per-member-row action buttons (Rotate/Add credential + Reset password) with a single edit affordance (click member name or an edit button) opening a member-detail editor for all of a member's details (display name, local-login password, Fastmail/CalDAV app password) with clear non-jargon labels that retire "Rotate"; and collapse the "Add member" form behind a single trigger by default. Seeded by a UX gripe during Phase 17 verification that "Rotate" for the app password is unintuitive. Client-side AdminPage + CredentialSheet rework over existing `/api/admin` endpoints; no new authorization boundary. Scope still needs definition — run /gsd-discuss-phase 20. Milestone window now Phases 7–20. ### Pending Todos diff --git a/.planning/phases/20-admin-member-editor-form-declutter/.gitkeep b/.planning/phases/20-admin-member-editor-form-declutter/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/.gitkeep @@ -0,0 +1 @@ + From 91948919a8a7aeeb22e3d7c0a8ab825eafe966f1 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 16:42:19 -0400 Subject: [PATCH 02/34] docs(20): capture phase context --- .../20-CONTEXT.md | 112 ++++++++++++++++++ .../20-DISCUSSION-LOG.md | 83 +++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 .planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md create mode 100644 .planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md b/.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md new file mode 100644 index 0000000..fe9e36e --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-CONTEXT.md @@ -0,0 +1,112 @@ +# Phase 20: Admin Member Editor & Form Declutter - Context + +**Gathered:** 2026-06-18 +**Status:** Ready for planning + + +## Phase Boundary + +Rework the **admin Members panel** (`apps/pwa/src/routes/AdminPage.tsx`, "Members & Accounts" tab) so an admin edits all of a member's details from **one editor** instead of scattered per-row action buttons: + +- Replace the per-row `Rotate` / `Add credential` button **and** the separate `Reset password` button with a single member editor opened from the row. +- The editor changes: **display name**, **local-login password**, the **Fastmail/CalDAV app password** (calendar credential), and the member's **admin flag (`is_admin`)** — using clear, non-jargon labels that **retire the "Rotate" term**. +- Collapse the always-open inline **Add member** form behind a single "Add member" trigger. + +Primarily a client-side `AdminPage` + `CredentialSheet` rework over the existing `/api/admin` surface. **No new auth/authorization boundary** — everything stays behind `requireAdmin`. One small new *route* (member-profile update) is in scope; it is not a new boundary. Seeded by the gripe that "Rotate" for the app password is unintuitive. + +**Out of scope (deferred):** editable member color, admin-driven OIDC link/unlink, member deletion/removal. + + + +## Implementation Decisions + +### Editor field scope +- **D-01:** The editor exposes **four** things: display name, local-login password, Fastmail/CalDAV app password, and the **admin toggle (`is_admin`)**. Color, OIDC link/unlink, and remove-member are explicitly deferred (see Deferred Ideas). +- **D-02:** **Editing display name + `is_admin` needs one new route** within the existing `requireAdmin` boundary — today `displayName` is only written at member-create (`POST /members`) and there is no member-update route. Recommended shape: a single `PATCH /api/admin/members/:id` (or `POST`) accepting `displayName` and/or `is_admin`; exact verb/shape is the planner's call. `AdminMember` (`apps/pwa/src/api/client.ts:563`) and the `GET /members` select (`apps/api/src/routes/admin.ts:102`) must surface `isAdmin` for the toggle's initial state. + +### Admin toggle safety +- **D-03:** **Server blocks demoting the last admin.** Toggling `is_admin` off is rejected (409/422) when the target is the only remaining admin; self-demotion is permitted only when another admin exists. The client surfaces this as a clear inline error. The Phase 19 break-glass CLI/host command remains the true lockout-recovery path ([[19-CONTEXT]] D-13) — no new role/capability model. + +### Edit affordance +- **D-04:** **Whole-row tap opens the editor**, with a trailing chevron / edit icon as the affordance signal. The current per-row action buttons (`Rotate`/`Add credential`, `Reset password`) are removed from `MemberRow`. Big mobile tap target; matches the low-friction, warm aesthetic. + +### Editor layout & save model +- **D-05:** **One sheet, per-section save** — not a single combined Save. Sections: + 1. **Profile** — display name input + admin toggle, with one Save (writes the new member-profile route; subject to D-03 guard). + 2. **Set new password** — optional, **write-only** (blank = unchanged), with confirm; maps to existing `POST /members/:id/password` (`admin.ts:225`). Only shown for members with a local credential (`hasLocalCredential`). + 3. **Set app password** — optional, **write-only**; collects Fastmail email + app password, **CalDAV-validated** before store; maps to existing `POST /credentials` (`admin.ts:279`). + Each section maps 1:1 to an endpoint, avoiding partial-failure ambiguity when CalDAV validation fails. Passwords are never prefilled/returned to the client (preserve T-10-15/T-10-16). +- **D-06:** **Retire "Rotate" copy** everywhere; use plain labels (e.g. "Set app password" / "Update calendar password"). The standalone `ResetPasswordSheet` (currently in `AdminPage.tsx`) is **folded into** the editor's "Set new password" section — no separate reset sheet remains. + +### Add-member declutter +- **D-07:** **"Add member" opens a sheet**, not an inline-expanded form. Preferred: the **same Member sheet in a create mode** (compose_event-style — one component, create vs edit), so the panel collapses to a clean member list + a single "Add member" trigger. Create mode keeps today's fields (display name, username, initial password + confirm → `POST /members`). + +### Claude's Discretion +- Exact new-route verb/path/shape for the member-profile update (D-02). +- Whether the Member editor and Add-member sheet are literally one component with a mode prop vs two siblings sharing a base — planner's call, but D-07 prefers unification. +- In edit mode, whether the app-password section prefills/display the stored Fastmail email (read-only) or requires re-entry — minor UX detail for planning; note the stored `fastmailEmail` exists on the credential. +- Icon choice for the row chevron/edit affordance (lucide, consistent with existing `CheckCircle`/`AlertCircle` usage). + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase definition +- `.planning/ROADMAP.md` §"Phase 20: Admin Member Editor & Form Declutter" — goal + the open-scope note this discussion resolved. + +### Code being reworked +- `apps/pwa/src/routes/AdminPage.tsx` — the Members panel, `MemberRow`, the inline Add-member form, and the standalone `ResetPasswordSheet` being consolidated. +- `apps/pwa/src/components/CredentialSheet.tsx` — dialog/focus-trap/Escape + CalDAV-validation sheet to generalize into the Member editor (and Add-member create mode). +- `apps/pwa/src/api/client.ts` — `AdminMember` type (`:563`), admin fetchers (`fetchAdminMembers`, `fetchCreateMember`, `fetchAdminResetPassword`, `saveCredential`); add the new member-profile fetcher + `isAdmin` field here. +- `apps/api/src/routes/admin.ts` — existing endpoints: `GET /members` (`:102`), `POST /members` (`:143`), `POST /members/:id/password` (`:225`), `POST /credentials` (`:279`); add the member-profile update route here behind the same `requireAdmin`. + +### Prior decisions that constrain this phase +- `.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md` — D-11 (password lifecycle = self-change + admin-reset, no email reset), D-12 (OIDC link is self-service only, deletes local credential), D-13 (single `is_admin`, break-glass = CLI/host, no role split). + + + +## Existing Code Insights + +### Reusable Assets +- **`CredentialSheet`**: full dialog scaffold (role=dialog, `aria-modal`, `useFocusTrap`, Escape-to-close, focus-return-to-trigger, phone bottom-sheet vs desktop modal, CalDAV-validating mutation). Generalize into the Member editor + Add-member create mode. +- **`ResetPasswordSheet`** (in `AdminPage.tsx`): password + confirm + mismatch validation logic — folds into the editor's "Set new password" section (D-06). +- **`useFocusTrap`, `useIsPhone`** hooks — reuse for the new sheet. +- **Existing endpoints** cover login-password reset and app-password set; only the member-profile (displayName + is_admin) write is new. + +### Established Patterns +- Two-tab `AdminPage` ("Members & Accounts" / "Settings") with roving-tabindex tabs — keep; this phase only restructures the Members tab body. +- Mutations invalidate `['admin','members']` (+ `['me']` for credential changes) on success; success toast via `showToast` (D-08 pattern). Reuse for editor saves. +- Write-only password handling: never prefill, never log, `autoComplete="new-password"` (T-10-15/16). + +### Integration Points +- New `PATCH/POST /api/admin/members/:id` mounts on `adminRouter` behind `requireAdmin` (no new boundary). +- `GET /members` select + `AdminMember` type gain `isAdmin` so the editor's toggle has initial state. +- `MemberRow` becomes a single tappable row (chevron affordance), dropping its action-button cluster. + + + +## Specific Ideas + +- "Same sheet, create vs edit mode" is explicitly modeled on the Fastmail `compose_event` pattern (one widget, `id` present = edit, absent = create) — apply that shape to the Member sheet. +- Labels must read for a non-technical household member: retire "Rotate"; prefer "Set app password" / "Set new password" / plain "Save". + + + +## Deferred Ideas + +- **Editable member color** — colors are currently derived by row index (`var(--color-member-N)`); there is no stored per-member color to edit. Would need schema + assignment UX. → backlog / future phase. +- **Admin-driven OIDC link/unlink** — Phase 19 D-12 makes OIDC linking a self-service action performed *as that user*, never by an admin. Admin-side link/unlink is a different security model. → out of scope. +- **Remove / delete member** — destructive, with cascade concerns (events, lists, credentials, last-admin). Not part of the gripe-seeded scope. → backlog / future phase. + +### Reviewed Todos (not folded) +- "Gitea CI — full regression + Docker publish" (score 0.6) — stale keyword match (already delivered as Phase 8); unrelated to this UI phase. +- "PWA phone layout — BottomTabBar overlaps FAB + legend" (score 0.4) — already addressed in Phase 17; unrelated. + + + +--- + +*Phase: 20-admin-member-editor-form-declutter* +*Context gathered: 2026-06-18* diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md b/.planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md new file mode 100644 index 0000000..7a684e5 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-DISCUSSION-LOG.md @@ -0,0 +1,83 @@ +# Phase 20: Admin Member Editor & Form Declutter - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-06-18 +**Phase:** 20-admin-member-editor-form-declutter +**Areas discussed:** Editor field scope, Edit affordance, Editor layout & save model, Add-member declutter, Admin toggle safety + +--- + +## Editor field scope + +| Option | Description | Selected | +|--------|-------------|----------| +| Core 3 only | Display name + local login password + app password; defer admin toggle/color/OIDC/remove. | | +| Core 3 + admin toggle | Also flip `is_admin` from the editor, with a last-admin guard. | ✓ | + +**User's choice:** "The entire scope plus admin toggle" — core 3 fields plus the `is_admin` toggle. +**Notes:** Color, OIDC link/unlink, and remove-member stay deferred. Display name + admin toggle require one new within-`requireAdmin` route (no member-update route exists today). + +--- + +## Edit affordance + +| Option | Description | Selected | +|--------|-------------|----------| +| Whole-row tap + chevron | Tapping anywhere on the member row opens the editor; trailing chevron signals it. | ✓ | +| Name link + pencil button | Literal roadmap wording — name link + dedicated edit icon. | | + +**User's choice:** Whole-row tap + chevron. +**Notes:** Removes the per-row `Rotate`/`Add credential` + `Reset password` button cluster. + +--- + +## Editor layout & save model + +| Option | Description | Selected | +|--------|-------------|----------| +| One sheet, per-section save | Name+toggle save; "Set new password"; "Set app password" — each independent, maps 1:1 to an endpoint. | ✓ | +| One sheet, single combined Save | One Save writes every changed field; needs partial-failure handling for CalDAV validation. | | + +**User's choice:** One sheet, per-section save. +**Notes:** Avoids partial-failure ambiguity when CalDAV validation fails mid-save. Passwords stay write-only (blank = unchanged). + +--- + +## Add-member declutter + +| Option | Description | Selected | +|--------|-------------|----------| +| Open as a sheet | "Add member" opens a sheet — ideally the same Member sheet in create mode. | ✓ | +| Expand inline form | Button toggles the existing inline form visible/hidden in place. | | + +**User's choice:** Open as a sheet. +**Notes:** Prefer the compose_event-style one-component create-vs-edit pattern so the panel collapses to a clean list + one button. + +--- + +## Admin toggle safety + +| Option | Description | Selected | +|--------|-------------|----------| +| Block demoting last admin | Server rejects toggling `is_admin` off when they're the only admin; self-demotion only if another admin exists. | ✓ | +| Warn but allow | Confirm dialog when demoting the last admin/yourself, but permit it; rely on break-glass CLI. | | + +**User's choice:** Block demoting the last admin (server-enforced). +**Notes:** Phase 19 break-glass CLI/host command remains the true lockout-recovery path; no new role model. + +--- + +## Claude's Discretion + +- Exact verb/path/shape of the new member-profile update route (`displayName` + `is_admin`). +- Whether the Member editor and Add-member sheet are literally one component (mode prop) vs two siblings on a shared base. +- Whether the app-password section prefills the stored Fastmail email (read-only) or requires re-entry in edit mode. +- Chevron/edit icon choice for the row affordance. + +## Deferred Ideas + +- Editable member color (no stored per-member color — derived by row index). +- Admin-driven OIDC link/unlink (Phase 19 D-12: self-service only). +- Remove / delete member (destructive, cascade concerns). From fe0325ec431c40f25fa0c3a81a59602bb200346c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 16:42:23 -0400 Subject: [PATCH 03/34] docs(state): record phase 20 context session --- .planning/STATE.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index bd3a128..76e94fb 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,16 +5,16 @@ milestone_name: Operability & Polish current_phase: 18 current_phase_name: Auto timezone detection and ability to change timezone status: executing -stopped_at: Phase 17 UI-SPEC approved -last_updated: "2026-06-18T17:11:30.158Z" +stopped_at: Phase 20 context gathered +last_updated: "2026-06-18T20:42:23.831Z" last_activity: 2026-06-18 last_activity_desc: Phase 17 complete, transitioned to Phase 18 progress: - total_phases: 26 + total_phases: 27 completed_phases: 12 total_plans: 55 completed_plans: 54 - percent: 46 + percent: 44 --- # Project State @@ -274,9 +274,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-18T15:00:30.986Z -Stopped at: Phase 17 UI-SPEC approved -Resume file: .planning/phases/17-ui-optimization-polish/17-UI-SPEC.md +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 ## Operator Next Steps From 96193831c4f28262148a60b8a75cb42e5d8d6d3a Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 16:50:31 -0400 Subject: [PATCH 04/34] docs(20): UI design contract --- .../20-UI-SPEC.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 .planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md diff --git a/.planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md b/.planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md new file mode 100644 index 0000000..8a9b2b3 --- /dev/null +++ b/.planning/phases/20-admin-member-editor-form-declutter/20-UI-SPEC.md @@ -0,0 +1,317 @@ +--- +phase: 20 +slug: admin-member-editor-form-declutter +status: draft +shadcn_initialized: false +preset: none +created: 2026-06-18 +--- + +# Phase 20 — UI Design Contract + +> Visual and interaction contract for the Admin Member Editor & Form Declutter phase. +> Generated by gsd-ui-researcher, verified by gsd-ui-checker. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | none — custom CSS custom properties | +| Preset | not applicable | +| Component library | none — inline styles referencing CSS tokens | +| Icon library | lucide-react (existing: CheckCircle, AlertCircle, Loader2; add ChevronRight) | +| Font | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif | + +No `components.json` detected. Project uses a hand-rolled design token system declared in +`apps/pwa/src/styles/tokens.css`. All new components must reference those CSS custom properties +(never hard-code hex/px). This is an established project convention — do not introduce shadcn or +any third-party component library in this phase. + +--- + +## Spacing Scale + +All tokens sourced from `apps/pwa/src/styles/tokens.css` (pre-populated — no change needed): + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-1` | 4px | Icon gaps, label-to-input margin | +| `--space-2` | 8px | Badge icon gap, toast icon gap, button row gap | +| `--space-3` | 12px | Field gap inside editor sections, tab padding | +| `--space-4` | 16px | Default field padding, section heading margin-bottom | +| `--space-6` | 24px | Sheet padding, section gap between Profile / Password / App-password | +| `--space-8` | 32px | Section bottom margin in Members panel | +| `--space-12` | 48px | Page top/bottom padding | + +Exceptions: +- Member row minimum height: 44px (touch target floor — not a spacing token) +- Sheet border-radius: 12px phone bottom-sheet top corners / 12px desktop modal all corners (matches existing CredentialSheet / ResetPasswordSheet) +- MemberRow add-member trigger button bottom margin: `--space-4` below member list, above the trigger + +--- + +## Typography + +All values sourced from `tokens.css` (pre-populated — no change): + +| Role | Size | Weight | Line Height | Usage in this phase | +|------|------|--------|-------------|---------------------| +| Body | 15px (`--text-body-size`) | 400 (`--text-body-weight`) | 1.5 (`--text-body-line-height`) | Member display name in row; section description text; subtitle line under sheet heading | +| Label | 13px (`--text-label-size`) | 400 or 600 | 1.4 (`--text-label-line-height`) | Form field labels (600), credential status badges (400), toast copy (400), section MEMBERS heading (600 + uppercase) | +| Heading | 18px (`--text-heading-size`) | 600 (`--text-heading-weight`) | 1.25 (`--text-heading-line-height`) | Sheet h2 ("Edit member", "Add member"), editor section dividers | +| Display | 24px (`--text-display-size`) | 600 (`--text-display-weight`) | 1.2 (`--text-display-line-height`) | Not used in this phase | + +--- + +## Color + +All values sourced from `tokens.css` (pre-populated): + +| Role | Value | Usage | +|------|-------|-------| +| Dominant (60%) | `--color-surface` (#ffffff) | Page background, sheet background, input background | +| Secondary (30%) | `--color-surface-dim` (#f7f7f8) / `--color-border-subtle` (#eceef2) | MemberRow bottom border, section dividers, "Add member" trigger button border | +| Accent (10%) | `--color-member-0` (#e8915a) | Primary action buttons only (see reserved-for list below) | +| Destructive | `--color-destructive` (#dc2626) | Inline error text, error-state input border | + +Accent (`--color-member-0`) reserved for: +1. Primary save/submit button background (enabled state) in the Member editor — each section's "Save" button +2. Active tab underline indicator in the Members & Accounts / Settings tab strip +3. "Currently shared" label text in the calendar picker (existing pattern, unchanged) +4. Toast success icon (`CheckCircle`) +5. "Use detected:" timezone affordance link (existing pattern, unchanged) + +Accent is NOT used on: +- Cancel buttons (use `--color-text-secondary`, no background) +- Disabled buttons (use `--color-border` as background) +- The "Add member" trigger button (use `--color-member-0` background — it is the primary CTA for the panel) +- The row chevron/edit icon (use `--color-text-muted`) + +Member avatar swatches use `--color-member-{colorIndex}` (index-derived, unchanged from existing MemberRow). + +--- + +## Surface Inventory + +This phase introduces or reworks the following surfaces: + +### Surface A — Member list (Members tab, existing panel body) + +The current inline "Add member" form and the per-row action-button cluster are removed. +The panel body becomes: + +``` +[Section label: MEMBERS] +[MemberRow] × N ← whole-row tap opens editor sheet; trailing ChevronRight icon +[Divider] +[+ Add member button] ← single trigger at bottom; opens MemberEditorSheet in create mode +``` + +MemberRow (reworked): +- Layout: avatar swatch (32px circle) → name + status badge → flex spacer → ChevronRight icon (16px, `--color-text-muted`) +- The entire row is the tap/click target (min-height 44px, `cursor: pointer`) +- `role="button"` on the row div, `aria-label="Edit {displayName}"`, `tabIndex={0}`, keyboard: Enter/Space opens editor +- Trailing admin badge: when `member.isAdmin === true`, show a small inline badge — label "Admin", 12px/600, `--color-member-0` text on `--color-surface-dim` background, border-radius 4px, padding 2px 6px — placed between the status badge and the chevron +- Remove: "Rotate" button, "Add credential" button, "Reset password" button — all removed from MemberRow + +"Add member" trigger button: +- Full-width or left-aligned ghost button with `+` icon prefix (use lucide `Plus`, 16px) +- Label: "Add member" +- Border: `1px solid var(--color-border)`, border-radius 8px, padding `--space-3` vertical / `--space-4` horizontal +- Background: `--color-surface`; hover/focus: `--color-surface-dim` +- Min-height: 44px +- Opens MemberEditorSheet in create mode + +### Surface B — Member editor sheet (MemberEditorSheet, new component) + +Single sheet component with `mode` prop: `'edit'` (id present) or `'create'` (no id). + +Sheet chrome (identical to CredentialSheet / ResetPasswordSheet): +- Phone: fixed bottom, `border-radius 12px 12px 0 0`, `box-shadow: 0 -4px 24px rgba(0,0,0,0.15)`, `padding: var(--space-6)`, zIndex 301 +- Desktop: centered modal, `max-width 480px`, `border-radius 12px`, `box-shadow: 0 8px 32px rgba(0,0,0,0.18)`, `padding: var(--space-6)`, zIndex 301 +- Backdrop: `rgba(0,0,0,0.32)` (`--color-overlay`), zIndex 300, click closes +- Escape closes; focus returns to trigger row on close +- `role="dialog"`, `aria-modal="true"`, `aria-label` = sheet heading +- `useFocusTrap` on dialog div +- On open: focus the h2 heading (`tabIndex={-1}`) +- Desktop: `max-height: calc(100dvh - var(--space-8))`, `overflow-y: auto` + +Sheet heading: +- `h2`, 18px/600/1.25, `--color-text-primary` +- Edit mode: "Edit member" +- Create mode: "Add member" + +Member subtitle (edit mode only): +- 15px/400, `--color-text-secondary`, `margin-bottom: var(--space-6)` +- Displays `member.displayName` + +**Edit mode — three collapsible sections rendered as labeled card blocks:** + +Section divider style (between sections): +- `border-top: 1px solid var(--color-border-subtle)`, `margin: var(--space-6) 0` + +**Section 1 — Profile** (always shown in edit mode): +- Section heading: "Profile", 13px/600/uppercase/letterspacing 0.06em, `--color-text-muted` +- Field: "Display name" — text input, 15px, min-height 44px, border `--color-border`, border-radius 4px, padding `--space-3 --space-4` +- Field: "Admin" — toggle/checkbox row: + - Label: "Admin", 15px/400, `--color-text-primary` + - Sub-label: "Can access admin settings", 13px/400, `--color-text-secondary` + - Control: native `` styled as a toggle (or a ` + + + {/* Last-admin guard inline error */} + {profileError && ( +
+ {profileError} +
+ )} + +
+ + +
+ + + {/* Section 2 — Set new password (only for members with local credential) */} + {member.hasLocalCredential && ( + <> +
+
+
Set new password
+
Leave blank to keep the current password.
+ + {/* New password */} +
+ + setNewPassword(e.target.value)} + aria-describedby={passwordError ? 'password-error' : undefined} + style={inputStyle(!!passwordError)} + /> +
+ + {/* Confirm new password */} +
+ + setConfirmPassword(e.target.value)} + aria-describedby={passwordError ? 'password-error' : undefined} + style={inputStyle(!!passwordError)} + /> +
+ + {/* Password error */} + {passwordError && ( +
+ {passwordError} +
+ )} + +
+ +
+
+ + )} + + {/* Section 3 — App password (always in edit mode) */} + <> +
+
+
App password
+ + {/* Helper text with link */} +
+ Fastmail app password scoped to Calendars & Contacts (CalDAV).{' '} + + {CALDAV_LINK_TEXT} + +
+ + {/* In-flight validating state */} + {appPasswordMutation.isPending && ( +
+
+ )} + + {/* Fastmail email */} +
+ + setFastmailEmail(e.target.value)} + placeholder="user@fastmail.com" + aria-describedby={appPasswordError ? 'app-password-error' : undefined} + style={inputStyle(!!appPasswordError)} + /> +
+ + {/* App password field — NEVER prefilled (T-20-07) */} +
+ + setAppPassword(e.target.value)} + aria-describedby={appPasswordError ? 'app-password-error' : undefined} + style={inputStyle(!!appPasswordError)} + /> +
+ + {/* CalDAV error */} + {appPasswordError && ( +
+ {appPasswordError} +
+ )} + +
+ +
+
+ + + )} + + {/* ── CREATE MODE ────────────────────────────────────────────────── */} + {mode === 'create' && ( +
+ {/* Display name */} +
+ + setCreateDisplayName(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Username */} +
+ + setCreateUsername(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Initial password */} +
+ + setCreatePassword(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Confirm password */} +
+ + setCreateConfirmPassword(e.target.value)} + aria-describedby={createError ? 'create-error' : undefined} + style={inputStyle(!!createError)} + /> +
+ + {/* Create error */} + {createError && ( +
+ {createError} +
+ )} + +
+ + +
+
+ )} +
+ + ); +} From 9e6b00454195b4d1ab8498f217540af3cb11ca2d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:35:37 -0400 Subject: [PATCH 16/34] feat(20-03): rework AdminPage MemberRow + Add-member trigger; remove old surfaces - MemberRow: whole-row role=button, aria-label='Edit {name}', trailing ChevronRight - Admin badge on member.isAdmin (12px/600, --color-member-0 on --color-surface-dim) - 'Add member' ghost trigger with Plus icon opens MemberEditorSheet in create mode - Single MemberEditorSheet replaces CredentialSheet + ResetPasswordSheet mounts - Removed: inline Local Accounts add-form, ResetPasswordSheet definition, create-form state - Empty state: 'No members yet' / 'Add a member to get started.' (UI-SPEC copy) --- apps/pwa/src/routes/AdminPage.tsx | 826 +++++------------------------- 1 file changed, 140 insertions(+), 686 deletions(-) diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index c953fcd..952eb24 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -9,10 +9,10 @@ * - Centered content column, maxWidth 640px on desktop * - var(--space-12) top/bottom padding, var(--space-6) horizontal padding * - * UI-SPEC §Surface 2 (MEMBERS section): - * - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge - * - "Rotate" or "Add credential" action button per hasCredential - * - Opens CredentialSheet in admin-rotate or admin-add mode + * UI-SPEC §Surface A (MEMBERS section — Phase 20): + * - Tappable MemberRow with trailing ChevronRight + "Admin" badge when isAdmin + * - Single "Add member" ghost trigger at the bottom (opens MemberEditorSheet in create mode) + * - No per-row action-button cluster; no always-open inline add form * * UI-SPEC §Surface 5 (SHARED CALENDAR section): * - Radio group, one row per synced calendar @@ -25,21 +25,18 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import { CheckCircle, AlertCircle, ChevronRight, Plus } from 'lucide-react'; import { fetchAdminMembers, fetchAdminCalendars, setSharedCalendar, fetchAdminTimezone, setAdminTimezone, - fetchCreateMember, - fetchAdminResetPassword, type AdminMember, type AdminCalendar, } from '../api/client.js'; -import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js'; +import { MemberEditorSheet } from '../components/MemberEditorSheet.js'; import { useIsPhone } from '../hooks/useIsPhone.js'; -import { useFocusTrap } from '../hooks/useFocusTrap.js'; // ── Styles ───────────────────────────────────────────────────────────────── @@ -78,24 +75,16 @@ export function AdminPage() { // Two-tab navigation state (D-10) const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members'); - // Credential sheet state - const [sheetOpen, setSheetOpen] = useState(false); - const [sheetMode, setSheetMode] = useState('admin-add'); - const [sheetMember, setSheetMember] = useState(null); - const triggerRef = useRef(null); - - // Reset-password sheet state (Surface 11B) - const [resetSheetOpen, setResetSheetOpen] = useState(false); - const [resetTargetMember, setResetTargetMember] = useState(null); - // resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close - const resetTriggerRef = useRef(null); - - // Create-member form state (Surface 11A) - const [createDisplayName, setCreateDisplayName] = useState(''); - const [createUsername, setCreateUsername] = useState(''); - const [createPassword, setCreatePassword] = useState(''); - const [createConfirmPassword, setCreateConfirmPassword] = useState(''); - const [createError, setCreateError] = useState(null); + // ── Member editor sheet state ─────────────────────────────────────────── + // A single MemberEditorSheet instance handles both edit (row tap) and + // create ("Add member" trigger) modes (D-07 unification). + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<'edit' | 'create'>('edit'); + const [editorMember, setEditorMember] = useState(null); + // Ref to the element that opened the sheet — focus returns here on close (a11y) + const editorTriggerRef = useRef(null); + // Ref for the "Add member" ghost trigger button + const addMemberTriggerRef = useRef(null); // Shared calendar picker state const [selectedCalendarId, setSelectedCalendarId] = useState(null); @@ -248,62 +237,21 @@ export function AdminPage() { )?.focus(); } - // Open credential sheet for a member - function openSheet(member: AdminMember, buttonRef: React.RefObject) { - // Capture the button so focus can return on close - (triggerRef as React.MutableRefObject).current = buttonRef.current; - setSheetMember(member); - setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add'); - setSheetOpen(true); + // Open the editor for a specific member (row tap → edit mode) + function openEditorForMember(member: AdminMember, rowEl: HTMLElement) { + editorTriggerRef.current = rowEl; + setEditorMember(member); + setEditorMode('edit'); + setEditorOpen(true); } - // Create-member mutation (Surface 11A) - const createMemberMutation = useMutation({ - mutationFn: async () => { - // Client-side validation (server also validates; this is for UX) - if (createPassword !== createConfirmPassword) { - throw new Error('mismatch'); - } - if (createPassword.length < 8) { - throw new Error('short'); - } - await fetchCreateMember({ - displayName: createDisplayName.trim(), - username: createUsername.trim(), - password: createPassword, - }); - }, - onSuccess: () => { - // Clear form + refresh member list - setCreateDisplayName(''); - setCreateUsername(''); - setCreatePassword(''); - setCreateConfirmPassword(''); - setCreateError(null); - void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); - void queryClient.invalidateQueries({ queryKey: ['me'] }); - showToast('Member added.'); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : 'server'; - if (msg === 'mismatch') { - setCreateError('Passwords do not match.'); - } else if (msg === 'short') { - setCreateError('Password is too short. Use at least 8 characters.'); - } else if (msg === 'conflict' || msg.includes('409')) { - setCreateError('That username is already in use. Choose a different one.'); - } else { - setCreateError('Something went wrong. Please try again.'); - } - }, - }); - - const createSubmitDisabled = - createMemberMutation.isPending || - createDisplayName.trim().length === 0 || - createUsername.trim().length === 0 || - createPassword.length === 0 || - createConfirmPassword.length === 0; + // Open the editor in create mode ("Add member" trigger) + function openEditorForCreate() { + editorTriggerRef.current = addMemberTriggerRef.current; + setEditorMember(null); + setEditorMode('create'); + setEditorOpen(true); + } const saveDisabled = sharedCalMutation.isPending || @@ -416,248 +364,79 @@ export function AdminPage() {
)} - {membersQuery.data && ( + {membersQuery.data && membersQuery.data.members.length === 0 && ( +
+
+ No members yet +
+
+ Add a member to get started. +
+
+ )} + + {membersQuery.data && membersQuery.data.members.length > 0 && (
{membersQuery.data.members.map((member, idx) => ( openSheet(member, buttonRef)} - onResetPassword={(buttonRef) => { - // Capture trigger button so focus can return on close - resetTriggerRef.current = buttonRef.current; - setResetTargetMember(member); - setResetSheetOpen(true); - }} + onEdit={(rowEl) => openEditorForMember(member, rowEl)} /> ))}
)} - - {/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */} -
-
Local Accounts
- - {/* Surface 11A — Add member inline form */} -
-
+
- - {/* Display name */} -
- - setCreateDisplayName(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Username */} -
- - setCreateUsername(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Initial password */} -
- - setCreatePassword(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Confirm password */} -
- - setCreateConfirmPassword(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Inline error */} - {createError && ( -
- {createError} -
- )} - - {/* Action row */} -
- -
+
@@ -1108,33 +887,15 @@ export function AdminPage() { )} - {/* Credential sheet — admin-rotate or admin-add */} - {sheetMember && ( - setSheetOpen(false)} - mode={sheetMode} - memberName={sheetMember.displayName} - memberId={sheetMember.id} - triggerRef={triggerRef} - /> - )} - - {/* Surface 11B — Reset password sheet */} - {resetTargetMember && ( - { - setResetSheetOpen(false); - // Return focus to trigger - if (resetTriggerRef.current) { - resetTriggerRef.current.focus(); - } - }} - onSuccess={() => showToast('Password reset.')} - member={resetTargetMember} - /> - )} + {/* Single unified MemberEditorSheet — handles edit + create modes (D-07) */} + setEditorOpen(false)} + mode={editorMode} + member={editorMember ?? undefined} + triggerRef={editorTriggerRef} + onToast={showToast} + /> ); } @@ -1144,16 +905,37 @@ export function AdminPage() { interface MemberRowProps { member: AdminMember; colorIndex: number; - onAction: (buttonRef: React.RefObject) => void; - onResetPassword?: (buttonRef: React.RefObject) => void; + /** Called with the row element so focus can return to it on editor close */ + onEdit: (rowEl: HTMLElement) => void; } -function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) { - const buttonRef = useRef(null); - const resetBtnRef = useRef(null); +/** + * Tappable member row (D-04): whole-row role="button" target with trailing + * ChevronRight affordance. Opens MemberEditorSheet in edit mode on tap/click + * or Enter/Space. Admin badge displayed when member.isAdmin. + */ +function MemberRow({ member, colorIndex, onEdit }: MemberRowProps) { + const rowRef = useRef(null); + + function handleActivate() { + if (rowRef.current) { + onEdit(rowRef.current); + } + } return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivate(); + } + }} style={{ display: 'flex', alignItems: 'center', @@ -1161,6 +943,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP minHeight: '44px', padding: 'var(--space-2, 8px) 0', borderBottom: '1px solid var(--color-border-subtle, var(--color-border))', + cursor: 'pointer', }} > {/* Avatar swatch */} @@ -1175,7 +958,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP }} /> - {/* Name + status */} + {/* Name + credential status badge */}
- {/* Action button row */} -
- {/* Credential rotate/add button */} - + Admin + + )} - {/* Surface 11B — Reset password button (only for members with a local credential) */} - {member.hasLocalCredential && onResetPassword && ( - - )} -
+ {/* Trailing chevron affordance */} +
); } @@ -1372,310 +1130,6 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr ); } -// ── ResetPasswordSheet ────────────────────────────────────────────────────── - -/** - * Surface 11B — Admin password reset sheet. - * Opens as a bottom sheet (mobile) / centered modal (desktop). - * Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger). - * No current-password field — admin reset does not require knowing the old password. - */ - -interface ResetPasswordSheetProps { - isOpen: boolean; - onClose: () => void; - onSuccess?: () => void; - member: AdminMember; -} - -function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) { - // WR-05: resize-aware phone detection. - const sheetPhone = useIsPhone(); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [error, setError] = useState(null); - const headingRef = useRef(null); - // WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true"). - const dialogRef = useRef(null); - const handleDialogKeyDown = useFocusTrap(dialogRef); - - // Escape closes the sheet - useEffect(() => { - if (!isOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [isOpen, onClose]); - - // Focus heading on open - useEffect(() => { - if (isOpen && headingRef.current) { - headingRef.current.focus(); - } - }, [isOpen]); - - function handleClose() { - setNewPassword(''); - setConfirmPassword(''); - setError(null); - onClose(); - } - - 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.'); - } - }, - }); - - const isPending = resetMutation.isPending; - const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0; - - if (!isOpen) return null; - - return ( - <> - {/* Backdrop */} -