Files
familysync/.planning/milestones/v1.1-phases/20-admin-member-editor-form-declutter/20-PATTERNS.md
T
2026-06-18 22:21:38 -04:00

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/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.tsGET /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)
  • 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:

// 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
  },
);
  • 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:

// 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.tsGET /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 (createDisplayNamecreateError, :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