17 KiB
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/adminendpoints. 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):
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)handleCloseviauseCallbackthat clears form state, callsonClose(), and returns focus totriggerRef.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 wantsrgba(0,0,0,0.32)/--color-overlay, which matchesResetPasswordSheet:1459(var(--color-overlay, rgba(0,0,0,0.32))), NOT CredentialSheet'srgba(0,0,0,0.4). Prefer the ResetPasswordSheet overlay token. - Phone-vs-desktop sheet style object (
:187-217) — copy exactly (borderRadius12px 12px 0 0phone /12pxdesktop,maxWidth 480px, zIndex 301,padding var(--space-6), desktopmaxHeight: 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:
// 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):
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
},
);
parsePositiveIntParamalready exists (admin.ts:87-92) — reuse, do NOT re-implement.requireAdminis 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:
// 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:
.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:
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:
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):
<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:
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.
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