Files
familysync/.planning/phases/20-admin-member-editor-form-declutter/20-REVIEW.md
T

15 KiB

phase, reviewed, depth, files_reviewed, files_reviewed_list, findings, status
phase reviewed depth files_reviewed files_reviewed_list findings status
20-admin-member-editor-form-declutter 2026-06-18T00:00:00Z standard 4
apps/api/src/routes/admin.ts
apps/pwa/src/api/client.ts
apps/pwa/src/components/MemberEditorSheet.tsx
apps/pwa/src/routes/AdminPage.tsx
critical warning info total
1 5 3 9
issues_found

Phase 20: Code Review Report

Reviewed: 2026-06-18 Depth: standard Files Reviewed: 4 Status: issues_found

Summary

Phase 20 adds PATCH /api/admin/members/:id (displayName + isAdmin), wires a unified MemberEditorSheet (edit/create modes), and declutters the AdminPage member list by replacing the always-open add form and per-row action buttons with a single tappable row + bottom trigger.

The authorization boundary is sound: requireAdmin does a live DB lookup on every request, mounted first on the router, and the new route inherits that correctly. Zod validation is applied consistently. The last-admin guard logic has one correctness defect (TOCTOU race — classified Critical) and several warning-level issues in the frontend.


Critical Issues

CR-01: Last-admin demotion guard is a TOCTOU race

File: apps/api/src/routes/admin.ts:251-258

Issue: The D-03 last-admin guard does a SELECT COUNT(*) then, if count > 1, proceeds to UPDATE. Between the COUNT and the UPDATE another concurrent request could demote the other admin simultaneously, leaving the system with zero admins. Both concurrent PATCH requests read count=2, both pass the guard, and both updates succeed — violating the "at least one admin must remain" invariant.

The guard logic is:

SELECT COUNT(*) WHERE is_admin=true  → 2
  (concurrent PATCH2 also reads 2)
UPDATE SET is_admin=false WHERE id=X  → ok
  (concurrent PATCH2 also runs)
UPDATE SET is_admin=false WHERE id=Y  → ok, now 0 admins

For a two-person household the window is narrow but the invariant is still breakable under network retry or any concurrent admin sessions.

Fix: Wrap the existence check, COUNT, and UPDATE in a single transaction and use SELECT ... FOR UPDATE to serialize the guard:

await db.transaction(async (tx) => {
  // Lock the target row
  const [target] = await tx
    .select({ id: users.id, isAdmin: users.isAdmin })
    .from(users)
    .where(eq(users.id, targetId))
    .limit(1)
    // Drizzle/mysql2: append raw FOR UPDATE via sql suffix or use db.execute
    // Alternative: use an atomic conditional UPDATE described below
    ;

  if (!target) throw new Error('not-found');

  if (isAdmin === false && target.isAdmin) {
    const [{ count }] = await tx
      .select({ count: sql<number>`COUNT(*)` })
      .from(users)
      .where(eq(users.isAdmin, true));
    if (Number(count) <= 1) throw new Error('last-admin');
  }

  const updates: { displayName?: string; isAdmin?: boolean } = {};
  if (displayName !== undefined) updates.displayName = displayName;
  if (isAdmin !== undefined) updates.isAdmin = isAdmin;
  await tx.update(users).set(updates).where(eq(users.id, targetId));
});

Or use an atomic conditional UPDATE to avoid the SELECT entirely:

UPDATE users
SET is_admin = false
WHERE id = :targetId
  AND (SELECT COUNT(*) FROM users WHERE is_admin = true) > 1

Check affectedRows === 0 to detect the guard firing without a round-trip.


Warnings

WR-01: Profile save always sends displayName even when only isAdmin changed

File: apps/pwa/src/components/MemberEditorSheet.tsx:262-265

Issue: profileMutation.mutationFn unconditionally sends { displayName: displayName.trim(), isAdmin } to the server. If the admin opens the sheet only to toggle the admin flag — touching nothing else — the current displayName (already persisted) is re-written. This is benign under normal conditions, but there is a subtle edge: the server's updateMemberSchema requires displayName to be min(1) when present. If a member somehow has an empty displayName in the DB and the editor initialises to '', the profile save fires a 400 because the trimmed displayName fails min(1) validation — even though the admin only wanted to change the admin flag. The UI provides no feedback for this case: the mutation fails with the generic "Something went wrong" copy, and the displayName state in the editor does not indicate an error because profileError is set but inputStyle(!!profileError) correctly highlights the field — however the user has no idea why touching only the toggle caused a validation failure.

A more specific problem: the "Save" button is disabled when displayName.trim().length === 0 (line 568), which prevents the user from saving. But there is no guard that prevents opening the editor for a member with a null/empty displayName and immediately being stuck — the button is disabled with no explanation.

Fix: Either (a) send only the changed fields (track dirtyDisplayName and dirtyIsAdmin flags), or (b) show an explicit validation error on the displayName field before the user even attempts to save:

// Option A: only include fields that changed
const payload: { displayName?: string; isAdmin?: boolean } = {};
if (displayName.trim() !== (member.displayName ?? '')) {
  payload.displayName = displayName.trim();
}
if (isAdmin !== member.isAdmin) {
  payload.isAdmin = isAdmin;
}
if (Object.keys(payload).length === 0) return; // nothing to update
await updateMemberProfile(member.id, payload);

WR-02: D-03 error revert uses stale fallback when member prop is undefined

File: apps/pwa/src/components/MemberEditorSheet.tsx:276

Issue: When the server returns a 409 (last-admin guard), the onError handler reverts isAdmin to member?.isAdmin ?? true. The ?? true default means that if member is somehow undefined at the time the error fires (e.g., the parent closes the sheet mid-flight), the revert silently sets isAdmin to true rather than the correct prior value. More practically: the mutation already guards if (!member) throw new Error('no-member') at line 261, so a missing member in onError is the result of a no-member throw, not a 409. The 409 path always has member available via the closure. However the ?? true default is semantically wrong — if it ever fires it could reverse the toggle into an incorrect state and mislead the user.

Fix: Use the value that was stable at mutation start:

const profileMutation = useMutation({
  mutationFn: async () => {
    if (!member) throw new Error('no-member');
    const previousIsAdmin = member.isAdmin; // captured at call time
    await updateMemberProfile(member.id, { displayName: displayName.trim(), isAdmin });
    return { previousIsAdmin };
  },
  onError: (err, _vars, context) => {
    // context.previousIsAdmin is the value at mutation start
    ...
  },
});

Alternatively, capture member.isAdmin at the time the button is clicked and pass it through mutation context.

WR-03: Phone bottom-sheet has no max-height / scroll, content can overflow on tall forms

File: apps/pwa/src/components/MemberEditorSheet.tsx:379-393

Issue: The desktop sheet style (lines 399-407) correctly applies maxHeight: 'calc(100dvh - var(--space-8, 32px))' and overflowY: 'auto'. The phone bottom-sheet style (lines 380-392) has neither. In edit mode with all three sections visible (profile + password + app password), the sheet content can easily exceed the viewport height — on a short phone (SE-sized) it will push content off screen with no scroll affordance, making the "Save app password" button unreachable. The UI-SPEC §Surface B does not specify a phone max-height, but the same overflow scenario exists.

Fix: Add a maxHeight and overflowY: 'auto' to the phone style:

// phone sheet style:
{
  position: 'fixed',
  bottom: 0,
  left: 0,
  right: 0,
  maxHeight: 'calc(85dvh)',   // or 'calc(100dvh - env(safe-area-inset-top, 0px))'
  overflowY: 'auto',
  background: 'var(--color-surface)',
  borderRadius: '12px 12px 0 0',
  ...
}

WR-04: MemberEditorSheet handleClose dependency on member causes stale-close on concurrent edits

File: apps/pwa/src/components/MemberEditorSheet.tsx:210-231

Issue: handleClose is memoised with useCallback and depends on [onClose, triggerRef, member?.displayName, member?.isAdmin]. The closure captures member at memoisation time. When a section save succeeds, queryClient.invalidateQueries triggers a refetch, membersQuery.data updates, and AdminPage may pass a new member object to the sheet (with updated displayName/isAdmin). At that point handleClose is still holding the old captured values for the form reset lines:

setDisplayName(member?.displayName ?? '');  // uses stale member
setIsAdmin(member?.isAdmin ?? false);

If the user saves the Profile section (updating displayName), then clicks Cancel, the form resets to the pre-save value rather than the now-persisted value. This is a visual inconsistency: the field resets to outdated data. The useEffect at line 234 (syncing state on member?.id change) does handle re-sync when a different row is opened — but not when the same row's data refreshes while the sheet is open.

Fix: Either remove the reset logic from handleClose (instead rely solely on the useEffect sync), or widen the effect's dependency to also fire when member?.displayName or member?.isAdmin changes while the sheet is open:

useEffect(() => {
  // Re-sync whenever the member data changes (including after a save + refetch)
  setDisplayName(member?.displayName ?? '');
  setIsAdmin(member?.isAdmin ?? false);
  setProfileError(null);
}, [member?.id, member?.displayName, member?.isAdmin]);

The useEffect already has these dependencies (line 238) — so the sync is correct on open and on member-change. The remaining problem is only that handleClose also does a redundant reset using the stale closure. The simplest fix is to remove the redundant reset lines from handleClose and let the effect own that state:

const handleClose = useCallback(() => {
  // Only reset fields that don't correspond to member data (passwords, app pw)
  setProfileError(null);
  setNewPassword('');
  setConfirmPassword('');
  setPasswordError(null);
  setFastmailEmail('');
  setAppPassword('');
  setAppPasswordError(null);
  setCreateDisplayName('');
  setCreateUsername('');
  setCreatePassword('');
  setCreateConfirmPassword('');
  setCreateError(null);
  onClose();
  if (triggerRef?.current) triggerRef.current.focus();
}, [onClose, triggerRef]);

WR-05: aria-describedby on profile display-name input points to an error id that may not be in DOM

File: apps/pwa/src/components/MemberEditorSheet.tsx:479

Issue: The display-name input has aria-describedby={profileError ? 'profile-error' : undefined}. The error <div id="profile-error"> is rendered at line 557, which is after the Section 1 save button. Screen readers resolving aria-describedby expect the referenced element to exist in the DOM at time of announcement — this is fine because the error node is in the same DOM tree. However there is a second problem: the profileError div is rendered below the action buttons (lines 556-559), not directly adjacent to the field it describes. WCAG 1.3.1 expects that error associations are explicit. More critically: the same profile-error id is also described by both the displayName input (line 479) and the admin toggle button (implicitly — it is the only error output for the section, but the toggle has no aria-describedby). The admin toggle button (line 519) has aria-label="Admin" but no aria-describedby="profile-error", so a screen-reader user who activates the toggle and receives the last-admin error won't have it announced.

Fix: Add aria-describedby="profile-error" to the toggle <button> element:

<button
  type="button"
  role="switch"
  aria-checked={isAdmin}
  aria-label="Admin"
  aria-describedby={profileError ? 'profile-error' : undefined}
  onClick={...}
  ...
>

Info

IN-01: PATCH /api/admin/members/:id returns 200 when no fields are supplied (empty update)

File: apps/api/src/routes/admin.ts:262-275

Issue: updateMemberSchema marks both displayName and isAdmin as optional. If the client sends {}, then updates is an empty object and db.update(users).set({}).where(...) is called with no column changes. Drizzle and mysql2 permit this and issue a no-op UPDATE (0 rows changed), returning { ok: true } with 200. This is harmless but semantically wrong — the server should return 400 if neither field is present. The client (updateMemberProfile in client.ts) always sends at least one field today, so this does not cause a user-visible bug. It is a latent issue for any future caller.

Fix: Add a Zod refinement or pre-flight check:

const updateMemberSchema = z.object({
  displayName: z.string().min(1).max(256).optional(),
  isAdmin: z.boolean().optional(),
}).refine((data) => data.displayName !== undefined || data.isAdmin !== undefined, {
  message: 'At least one field (displayName or isAdmin) must be provided',
});

IN-02: Create mode does not validate displayName max length client-side

File: apps/pwa/src/components/MemberEditorSheet.tsx:345-349

Issue: The create-mode submit fires fetchCreateMember with displayName: createDisplayName.trim(). The server schema enforces max(256), but the client has no maxLength attribute on the input (line 793-799) and no client-side length check. A user who types a very long name will get a generic 400 from the server with no useful copy. The edit-mode "Save" button is already disabled when displayName is empty, but neither mode caps the length visually or in validation.

Fix: Add maxLength={256} to the create-mode display-name input and the edit-mode display-name input, and optionally add a client-side length check before calling the API.

IN-03: Phone bottom-sheet does not account for safe-area-inset-bottom (notch/home-indicator)

File: apps/pwa/src/components/MemberEditorSheet.tsx:380-392

Issue: The phone sheet style places the sheet at bottom: 0 with no paddingBottom for the iOS home-indicator or Android gesture bar. On notched devices the bottom padding/safe-area is missing, so the Cancel and Save buttons may be hidden behind the system gesture bar. The existing AdminPage scroll container accounts for this via calc(56px + env(safe-area-inset-bottom, 0px)) (line 269), but the sheet itself does not.

Fix: Add bottom padding that respects the safe-area-inset:

// phone sheet style:
{
  position: 'fixed',
  bottom: 0,
  left: 0,
  right: 0,
  paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
  // ... rest unchanged
}

Reviewed: 2026-06-18 Reviewer: Claude (gsd-code-reviewer) Depth: standard