docs(phase-20): complete phase execution — verification passed (9/9), advisory review
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
80fd683877
commit
dec8220da3
@@ -453,7 +453,7 @@ Plans:
|
|||||||
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
|
| 17. UI Optimization & Polish | v1.1 | 6/6 | Complete | 2026-06-18 |
|
||||||
| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 |
|
| 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 |
|
| 19. Local Auth (No-OIDC Mode) | v1.1 | 5/5 | Complete | 2026-06-17 |
|
||||||
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
|
| 20. Admin Member Editor & Declutter | v1.1 | 3/3 | Complete | 2026-06-18 |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
+9
-8
@@ -2,13 +2,13 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
current_phase: 20
|
current_phase: 999.1
|
||||||
current_phase_name: admin-member-editor-form-declutter
|
current_phase_name: BACKLOG
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: Phase 20 UI-SPEC approved
|
stopped_at: Phase 20 UI-SPEC approved
|
||||||
last_updated: "2026-06-18T21:40:44.719Z"
|
last_updated: "2026-06-18T21:47:42.443Z"
|
||||||
last_activity: 2026-06-18
|
last_activity: 2026-06-18
|
||||||
last_activity_desc: Phase 20 execution started
|
last_activity_desc: Phase 20 complete, transitioned to Phase 999.1
|
||||||
progress:
|
progress:
|
||||||
total_phases: 27
|
total_phases: 27
|
||||||
completed_phases: 13
|
completed_phases: 13
|
||||||
@@ -28,10 +28,10 @@ See: .planning/PROJECT.md (updated 2026-06-16)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 20 (admin-member-editor-form-declutter) — EXECUTING
|
Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
|
||||||
Plan: 2 of 3
|
Plan: Not started
|
||||||
Status: Ready to execute
|
Status: Ready to execute
|
||||||
Last activity: 2026-06-18 — Phase 20 execution started
|
Last activity: 2026-06-18 — Phase 20 complete, transitioned to Phase 999.1
|
||||||
|
|
||||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
|
|||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 66
|
- Total plans completed: 69
|
||||||
- Average duration: -
|
- Average duration: -
|
||||||
- Total execution time: 0 hours
|
- Total execution time: 0 hours
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ Done 2026-06-12. Gitea branch protection on `main` now requires EXACTLY `CI / fa
|
|||||||
| 12 | 7 | - | - |
|
| 12 | 7 | - | - |
|
||||||
| 19 | 5 | - | - |
|
| 19 | 5 | - | - |
|
||||||
| 17 | 6 | - | - |
|
| 17 | 6 | - | - |
|
||||||
|
| 20 | 3 | - | - |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
---
|
||||||
|
phase: 20-admin-member-editor-form-declutter
|
||||||
|
reviewed: 2026-06-18T00:00:00Z
|
||||||
|
depth: standard
|
||||||
|
files_reviewed: 4
|
||||||
|
files_reviewed_list:
|
||||||
|
- apps/api/src/routes/admin.ts
|
||||||
|
- apps/pwa/src/api/client.ts
|
||||||
|
- apps/pwa/src/components/MemberEditorSheet.tsx
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
findings:
|
||||||
|
critical: 1
|
||||||
|
warning: 5
|
||||||
|
info: 3
|
||||||
|
total: 9
|
||||||
|
status: 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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_
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
phase: 20-admin-member-editor-form-declutter
|
||||||
|
verified: 2026-06-18T00:00:00Z
|
||||||
|
status: passed
|
||||||
|
score: 9/9 must-haves verified
|
||||||
|
behavior_unverified: 0
|
||||||
|
overrides_applied: 0
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 20: Admin Member Editor & Form Declutter Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Replace the per-member-row action buttons (Rotate/Add credential + Reset password) with a SINGLE edit affordance — tapping a member opens a member-detail editor where an admin modifies all of that member's details in one place. Also collapse the "Add member" section behind a single trigger.
|
||||||
|
**Verified:** 2026-06-18
|
||||||
|
**Status:** passed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|---------|
|
||||||
|
| 1 | An admin can update a member's display name and admin flag through one route behind requireAdmin | VERIFIED | `adminRouter.patch('/members/:id', zValidator(...), handler)` at admin.ts:230; `adminRouter.use('*', requireAdmin)` at admin.ts:48 — no second guard in the PATCH handler |
|
||||||
|
| 2 | Demoting the only remaining admin is rejected with a 409 and the member stays admin | VERIFIED | admin.ts:251-258: counts admins with `sql\`COUNT(*)\`` where `users.isAdmin` is true; returns `c.json({ error: 'Cannot remove the last admin' }, 409)` when count <= 1. Test C in admin.test.ts asserts 409 + subsequent GET confirms isAdmin still true |
|
||||||
|
| 3 | Self-demotion succeeds while another admin exists | VERIFIED | Same guard only fires when `count <= 1`; Test D seeds two admins and asserts 200 + one admin remaining |
|
||||||
|
| 4 | GET /api/admin/members returns each member's isAdmin so the editor toggle has correct initial state | VERIFIED | admin.ts:109: `isAdmin: users.isAdmin` in select; admin.ts:121: `isAdmin: row.isAdmin` in mapped object. Test H asserts boolean `isAdmin` on each member object |
|
||||||
|
| 5 | The PWA can call the member-profile update route and receive a typed result; 409/422 surfaces as a last-admin sentinel | VERIFIED | client.ts:249-264: `updateMemberProfile` issues `PATCH /api/admin/members/${memberId}`, maps 409/422 to `throw new Error('last-admin')`, maps 401/opaqueredirect to `SessionExpiredError` |
|
||||||
|
| 6 | AdminMember carries isAdmin so the editor toggle can show the correct initial state | VERIFIED | client.ts:597: `isAdmin: boolean;` present in `AdminMember` interface with Phase 20 comment |
|
||||||
|
| 7 | Tapping a member row opens one editor sheet for all of that member's details | VERIFIED | AdminPage.tsx:929-933: `role="button"`, `tabIndex={0}`, `onClick={handleActivate}`, `onKeyDown` Enter/Space handler — full tap target. MemberEditorSheet imported and mounted at AdminPage.tsx:891. Per-section saves (Profile/Set new password/App password) all wired to live endpoints |
|
||||||
|
| 8 | Add member is collapsed behind a single trigger that opens the same sheet in create mode | VERIFIED | AdminPage.tsx:408-440: single ghost button with `Plus` icon, `1px solid var(--color-border)`, opens `MemberEditorSheet` in `'create'` mode. No inline always-open add-form present |
|
||||||
|
| 9 | The terms Rotate, Add credential, and the standalone Reset password button no longer appear | VERIFIED | `grep -RnE '"Rotate"\|>Rotate<\|Add credential\|Reset password' apps/pwa/src/routes/AdminPage.tsx apps/pwa/src/components/MemberEditorSheet.tsx` — zero matches. `ResetPasswordSheet` absent from AdminPage.tsx |
|
||||||
|
|
||||||
|
**Score:** 9/9 truths verified (0 present, behavior-unverified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `apps/api/src/routes/admin.ts` | PATCH /api/admin/members/:id + isAdmin in GET /members select | VERIFIED | Route at line 230; `isAdmin: users.isAdmin` in select at line 109; last-admin guard at lines 251-258; `updateMemberSchema` Zod schema at line 225 |
|
||||||
|
| `apps/api/tests/routes/admin.test.ts` | Tests A-H for PATCH route + isAdmin in GET | VERIFIED | Tests A-H present (lines 1079-1260+); test C asserts 409 last-admin guard; test H asserts isAdmin boolean per member |
|
||||||
|
| `apps/pwa/src/api/client.ts` | `updateMemberProfile` fetcher + `AdminMember.isAdmin` | VERIFIED | `updateMemberProfile` at line 249 (PATCH verb, correct URL); `isAdmin: boolean` on `AdminMember` at line 597; `last-admin` sentinel at line 262 |
|
||||||
|
| `apps/pwa/src/components/MemberEditorSheet.tsx` | Single editor, edit+create modes, per-section saves, retired Rotate copy | VERIFIED | 899 lines; `mode: 'edit' | 'create'` prop; three edit-mode sections; `role="switch"` admin toggle; last-admin inline error; create mode with four fields; no "Rotate"/"Add credential"/"Reset password" literals |
|
||||||
|
| `apps/pwa/src/routes/AdminPage.tsx` | Tappable MemberRow + ChevronRight + single Add-member trigger; no per-row action cluster; no ResetPasswordSheet | VERIFIED | MemberRow has `role="button"`, `aria-label="Edit {displayName}"`, `tabIndex={0}`, Enter/Space handler; ChevronRight at line 1039; Admin badge at line 1022-1036; Plus ghost trigger at line 437; MemberEditorSheet mounted at line 891; zero ResetPasswordSheet references |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `AdminPage.tsx MemberRow` | `MemberEditorSheet.tsx` | `onEdit(rowEl) → openEditorForMember(member, rowEl) → setEditorOpen(true), setEditorMode('edit')` | WIRED | AdminPage.tsx:241-246 and 398-404; MemberEditorSheet imported at line 38 |
|
||||||
|
| `AdminPage.tsx "Add member" trigger` | `MemberEditorSheet.tsx create mode` | `openEditorForCreate() → setEditorMode('create'), setEditorOpen(true)` | WIRED | AdminPage.tsx:249-254 and 413 |
|
||||||
|
| `MemberEditorSheet.tsx Profile save` | `client.ts updateMemberProfile` | `updateMemberProfile(member.id, { displayName, isAdmin })` | WIRED | MemberEditorSheet.tsx:262; client.ts:249 |
|
||||||
|
| `client.ts updateMemberProfile` | `admin.ts PATCH /members/:id` | `fetch PATCH /api/admin/members/${memberId}` | WIRED | client.ts:253; admin.ts:230 |
|
||||||
|
| `admin.ts PATCH handler` | `db/schema.ts users.isAdmin` | `db.update(users).set(updates).where(eq(users.id, targetId))` | WIRED | admin.ts:267; COUNT query at line 253-256 |
|
||||||
|
| `MemberEditorSheet.tsx 409 onError` | `setProfileError('Cannot remove admin...')` | `msg === 'last-admin'` sentinel branch + toggle revert | WIRED | MemberEditorSheet.tsx:274-278 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data-Flow Trace (Level 4)
|
||||||
|
|
||||||
|
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||||
|
|----------|---------------|--------|--------------------|--------|
|
||||||
|
| `MemberEditorSheet.tsx` | `member` (prop) | `membersQuery.data?.members` in AdminPage → `fetchAdminMembers()` → `GET /api/admin/members` → DB select of users + joins | DB query returns live rows including `isAdmin` | FLOWING |
|
||||||
|
| `AdminPage.tsx MemberRow` | `member.isAdmin` | Same path above; `isAdmin: row.isAdmin` mapped from `users.isAdmin` column | Live boolean from DB | FLOWING |
|
||||||
|
| `MemberEditorSheet.tsx isAdmin toggle` | `useState(member?.isAdmin ?? false)` | Seeded from `member.isAdmin` on open and on member change via `useEffect` | Reflects live DB value on sheet open | FLOWING |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Behavioral Spot-Checks
|
||||||
|
|
||||||
|
| Behavior | Evidence | Status |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| Last-admin guard returns 409 and member stays admin | Test C in admin.test.ts (line 1128): asserts 409 response + subsequent GET confirms `isAdmin: true`. SUMMARY.md confirms 44 tests green | PASS |
|
||||||
|
| GET /members returns boolean isAdmin per member | Test H in admin.test.ts (line 1235): asserts boolean `isAdmin` on each member object | PASS |
|
||||||
|
| `updateMemberProfile` maps 409 to 'last-admin' sentinel | client.ts:262: `if (res.status === 409 || res.status === 422) throw new Error('last-admin')` — deterministic static analysis | PASS |
|
||||||
|
| Retired copy absent | grep on all four modified files — zero matches for "Rotate", "Add credential", "Reset password" | PASS |
|
||||||
|
| Playwright-cli verified UI contract | Screenshots in `screenshots/`: admin-members-tab-decluttered.png, member-editor-edit-mode.png, member-editor-create-mode.png, profile-save-toast.png — executor verified no retired buttons, row tap opens "Edit member", Add-member trigger opens "Add member", Profile save fires toast and sheet stays open | PASS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Probe Execution
|
||||||
|
|
||||||
|
No phase-specific probes declared. The orchestrator has confirmed 461/461 API tests green (includes the 8 new PATCH /members/:id tests) and PWA production build passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
Phase 20 PLANs declare `requirements: []` in all three frontmatter blocks. The REQUIREMENTS.md traceability table maps ADMIN-01, ADMIN-02, ADMIN-03 to Phase 10 — Phase 20 is a UI/UX improvement layer over those already-shipped requirements and does not introduce new REQ-IDs. No orphaned requirements for this phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Pattern | Severity | Impact |
|
||||||
|
|------|---------|----------|--------|
|
||||||
|
| None | — | — | — |
|
||||||
|
|
||||||
|
Zero TBD / FIXME / XXX markers in any of the four modified files. No stub patterns (empty returns, placeholder renders, hardcoded empty arrays). The `fastmailEmail` field starting blank in edit mode is intentional and documented via a code comment (the API does not return it), not a stub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
None. All behavioral checks were either:
|
||||||
|
- Covered by the 8 new integration tests (last-admin guard, isAdmin read, auth boundary, validation, 404)
|
||||||
|
- Verified by playwright-cli observation (four screenshots captured by executor)
|
||||||
|
- Verifiable statically (retired copy grep, artifact wiring, sentinel mapping)
|
||||||
|
|
||||||
|
No iOS-Safari-standalone or other device-only checks are in scope for this phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All 9 observable truths verified at all four levels (exists, substantive, wired, data flowing). The 3 CONTEXT decisions (D-01..D-07) are honored:
|
||||||
|
|
||||||
|
- D-01: Editor exposes exactly the four fields (displayName, password, app password, isAdmin)
|
||||||
|
- D-02: PATCH /members/:id in existing requireAdmin boundary; `AdminMember.isAdmin` surfaces the initial state
|
||||||
|
- D-03: Last-admin guard returns 409; client shows inline error and reverts toggle
|
||||||
|
- D-04: Whole-row `role="button"` with ChevronRight; per-row action cluster removed
|
||||||
|
- D-05: Per-section saves; sheet stays open after edit saves; closes only on create success
|
||||||
|
- D-06: "Rotate" / "Add credential" / "Reset password" retired from all files
|
||||||
|
- D-07: Single `MemberEditorSheet` component with `mode: 'edit' | 'create'` prop; inline add-form collapsed behind ghost trigger
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-06-18_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
Reference in New Issue
Block a user