277 lines
17 KiB
Markdown
277 lines
17 KiB
Markdown
# 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 `<div>` not `<h3>` to avoid heading-hierarchy issues under the `<h2>`.
|
|
|
|
**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<number>`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<void> {
|
|
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 `<button>`/tappable surface with a trailing `ChevronRight`.
|
|
|
|
**ChevronRight affordance — copy** (`ListCard.tsx:139-144`):
|
|
```typescript
|
|
<ChevronRight
|
|
size={16}
|
|
color="var(--color-text-muted)"
|
|
aria-hidden="true"
|
|
style={{ flexShrink: 0, marginLeft: 'var(--space-2)' }}
|
|
/>
|
|
```
|
|
Import: `import { ChevronRight, Plus } from 'lucide-react';` (both already used elsewhere — `ListCard.tsx:21`, `ListsIndex.tsx:24`).
|
|
|
|
**Row interaction (UI-SPEC §Surface A):** make the row `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, `cursor: pointer`, Enter/Space opens the editor. The existing `CalendarRadioRow` (`AdminPage.tsx:1298-1318`) is a good in-file template for the `role` + `onKeyDown` Enter/Space handler:
|
|
```typescript
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(); }
|
|
}}
|
|
```
|
|
|
|
**Keep** the avatar swatch (`AdminPage.tsx:1167-1176`, `var(--color-member-${colorIndex})`) and the credential status badge (`:1193-1235`, CheckCircle/AlertCircle "Credential set" / "No credential"). **Remove** the entire action-button cluster (`:1238-1285`: "Rotate"/"Add credential" + "Reset password").
|
|
|
|
**New Admin badge (UI-SPEC §Surface A):** when `member.isAdmin`, render an inline "Admin" pill (12px/600, `--color-member-0` text on `--color-surface-dim`, `border-radius 4px`, `padding 2px 6px`) — same shape as the "Shared" badge in `ListCard.tsx:124-133`.
|
|
|
|
**"Add member" trigger button (UI-SPEC §Surface A):** ghost button with `<Plus size={16}>` prefix, `1px solid var(--color-border)`, `border-radius 8px`, `padding var(--space-3) var(--space-4)`, `min-height 44px`, `--color-surface` bg / `--color-surface-dim` hover. The `Plus`-prefixed button pattern is at `CalendarShell.tsx:451-456`.
|
|
|
|
**Sheet wiring & state** — replace the dual `CredentialSheet` + `ResetPasswordSheet` mounting (`AdminPage.tsx:1111-1137`) with a single `MemberEditorSheet`. Reuse the existing `triggerRef` capture pattern (`:252-258`, `openSheet`) so focus returns to the tapped row on close. Add an `addMemberTriggerRef` for the create-mode trigger (focus returns there on cancel, per UI-SPEC Interaction Contract).
|
|
|
|
**Remove from this file:** the entire inline Add-member form (`:443-662`, the "Local Accounts" section body), the `ResetPasswordSheet` component definition (`:1375-1677`), and all create-form local state (`createDisplayName` … `createError`, `:94-98`) — these move into `MemberEditorSheet`.
|
|
|
|
---
|
|
|
|
## Shared Patterns
|
|
|
|
### TanStack Query mutation + invalidation
|
|
**Source:** `CredentialSheet.tsx:124-153` and `AdminPage.tsx` mutations.
|
|
**Apply to:** every per-section save in `MemberEditorSheet`.
|
|
```typescript
|
|
const m = useMutation({
|
|
mutationFn: async () => { /* call client fetcher */ },
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['me'] }); // credential/app-password changes only
|
|
showToast('…'); // per-section toast; sheet STAYS open (per-section save model, D-05)
|
|
},
|
|
onError: (err) => { /* map sentinel → inline error string */ },
|
|
});
|
|
```
|
|
Profile/password saves invalidate only `['admin','members']`; app-password save also invalidates `['me']` (needsProviderSetup refresh — `CredentialSheet.tsx:146-147`).
|
|
|
|
### Success toast (D-08)
|
|
**Source:** `AdminPage.tsx:66-76` (`showToast` + auto-dismiss effect) and the toast JSX (`:1068-1109`).
|
|
**Apply to:** all editor saves. Keep `showToast` in `AdminPage` and pass an `onToast`/`onSuccess` callback into `MemberEditorSheet` (the way `ResetPasswordSheet` receives `onSuccess` at `AdminPage.tsx:1134`), OR lift the toast into the sheet — planner's call. Toast copy per UI-SPEC: "Profile saved." / "Password updated." / "App password saved." / "Member added."
|
|
|
|
### Write-only password handling (T-10-15 / T-10-16)
|
|
**Source:** `CredentialSheet.tsx:299-319` (app password), `AdminPage.tsx:1544-1563` (reset password).
|
|
**Apply to:** every password/app-password field in the editor. Never prefill, `autoComplete="new-password"`, never log, blank = unchanged (D-05).
|
|
|
|
### Server admin boundary (no new boundary — D-02)
|
|
**Source:** `apps/api/src/lib/requireAdmin.ts` + `admin.ts:47` (`adminRouter.use('*', requireAdmin)`).
|
|
**Apply to:** the new `PATCH /members/:id` — it inherits the router-wide guard automatically. Do not add a second guard.
|
|
|
|
### noEchoHook for admin write routes
|
|
**Source:** `admin.ts:75-79`.
|
|
**Apply to:** the new member-profile route's `zValidator`.
|
|
|
|
---
|
|
|
|
## No Analog Found
|
|
|
|
None. Every new file and route has a direct, recent in-repo analog. The only genuinely new logic is the **D-03 last-admin guard**, and even that adapts the existing admin-count query from `apps/api/src/auth/user.ts:151-156`.
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Analog search scope:** `apps/pwa/src/components/`, `apps/pwa/src/routes/`, `apps/pwa/src/api/`, `apps/pwa/src/hooks/`, `apps/api/src/routes/`, `apps/api/src/lib/`, `apps/api/src/auth/`, `apps/api/src/db/`
|
|
**Files scanned:** ~12
|
|
**Pattern extraction date:** 2026-06-18
|