Compare commits
10
Commits
ee04aee4fb
...
f0aa901f57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0aa901f57 | ||
|
|
8829fd22b5 | ||
|
|
5161bd39c2 | ||
|
|
5240f1e503 | ||
|
|
41a4faec94 | ||
|
|
182ba1d477 | ||
|
|
400733fdc7 | ||
|
|
d2e9862849 | ||
|
|
2fd253ea95 | ||
|
|
527d85530c |
@@ -0,0 +1,59 @@
|
||||
---
|
||||
phase: 20-admin-member-editor-form-declutter
|
||||
fixed_at: 2026-06-18T14:45:00Z
|
||||
source_review: 20-REVIEW.md
|
||||
fix_scope: all
|
||||
findings_in_scope: 11
|
||||
fixed: 11
|
||||
skipped: 0
|
||||
iteration: 2
|
||||
status: all_fixed
|
||||
gates:
|
||||
typecheck: pass
|
||||
eslint: pass
|
||||
prettier: pass
|
||||
api_tests: 462/462
|
||||
---
|
||||
|
||||
# Phase 20 — Code Review Fix Report
|
||||
|
||||
Auto-fix pass over the deep review (`20-REVIEW.md`, 2 critical / 6 warning / 3 info).
|
||||
All 11 findings fixed and committed atomically; a deep re-review (iteration 2)
|
||||
independently confirmed 0 critical / 0 warning remain.
|
||||
|
||||
> Note: this report was reconstructed by the orchestrator — the fixer agent applied
|
||||
> and committed every fix but its `REVIEW-FIX.md` write did not persist. The commit
|
||||
> hashes below are the source of truth.
|
||||
|
||||
## Fixes applied
|
||||
|
||||
| ID | Severity | Fix | Commit |
|
||||
|----|----------|-----|--------|
|
||||
| CR-01 | Critical | Last-admin guard made atomic — single conditional UPDATE / `affectedRows` check closes the TOCTOU window; 409 response shape unchanged | `7297733` |
|
||||
| WR-06 | Warning | Empty `{}` PATCH body now rejected with a clean 400 via Zod refinement (was a Drizzle 503 on empty SET); new test added | `7297733` |
|
||||
| CR-02 | Critical | `AdminPage` derives the editor's member from live query data and refetches/invalidates after a save — no stale-snapshot demotion overwrite | `ee04aee` |
|
||||
| WR-01 | Warning | Toggle-only saves no longer re-send `displayName`, so admin-toggle saves don't 400 for OIDC-provisioned members with a null/empty stored name | `527d855` |
|
||||
| WR-02 | Warning | 409 revert uses the actual prior toggle state instead of defaulting to `true` | `527d855` |
|
||||
| WR-04 | Warning | `handleClose` closure fixed — Cancel after a per-section save no longer reverts to pre-save values | `527d855` |
|
||||
| WR-03 | Warning | Phone bottom-sheet gains `maxHeight` + `overflowY: auto` so the action button is reachable on short phones | `2fd253e` |
|
||||
| IN-03 | Info | Phone sheet adds `env(safe-area-inset-bottom)` padding (iOS home indicator) | `2fd253e` |
|
||||
| WR-05 | Warning | Admin toggle gains `aria-describedby` linking to the last-admin error region | `d2e9862` |
|
||||
| IN-01 | Info | Helper text shown when display name is empty | `400733f` |
|
||||
| IN-02 | Info | `maxLength` added to display-name and username inputs | `182ba1d` |
|
||||
|
||||
(`41a4fae` — prettier formatting of the updated `admin.test.ts`.)
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm -r typecheck` — pass (API + PWA)
|
||||
- ESLint — 0 warnings
|
||||
- Prettier — clean
|
||||
- API integration tests — **462/462** (includes a new Test H asserting empty-body PATCH → 400)
|
||||
|
||||
## Introduced during fixes (caught by iteration-2 re-review)
|
||||
|
||||
- **IN-04 (Info, resolved):** The no-op profile-save path (`mutationFn` returns early on an
|
||||
empty payload) triggered `onSuccess`, firing the "Profile saved." toast and refetching the
|
||||
`['admin','members']` query even when nothing changed. Fixed in `5161bd3` — `mutationFn`
|
||||
now returns a `changed` flag and `onSuccess` skips the toast/refetch when no write occurred.
|
||||
Typecheck/eslint/prettier all pass.
|
||||
@@ -0,0 +1,457 @@
|
||||
---
|
||||
phase: 20-admin-member-editor-form-declutter
|
||||
reviewed: 2026-06-18T12:00:00Z
|
||||
depth: deep
|
||||
files_reviewed: 5
|
||||
files_reviewed_list:
|
||||
- apps/api/src/routes/admin.ts
|
||||
- apps/api/tests/routes/admin.test.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/components/MemberEditorSheet.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 6
|
||||
info: 3
|
||||
total: 11
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 20: Code Review Report (Deep Re-Review)
|
||||
|
||||
**Reviewed:** 2026-06-18
|
||||
**Depth:** deep (cross-file, call-chain, state-machine analysis)
|
||||
**Files Reviewed:** 5
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 20 adds `PATCH /api/admin/members/:id` (displayName + isAdmin update), a unified
|
||||
`MemberEditorSheet` (edit/create modes), and a decluttered `AdminPage` member list. The
|
||||
authorization boundary (`requireAdmin` first on the router, live DB lookup every request) is
|
||||
sound and correctly inherited by all new routes. Password write-only discipline is preserved
|
||||
across the PATCH→client→sheet→AdminPage chain. The credential echo-protection pattern and the
|
||||
`noEchoHook` usage are consistent and correct.
|
||||
|
||||
This deep pass confirms all nine findings from the prior standard review (re-verified against the
|
||||
current code — none have been remediated). Two are re-classified: CR-01 (TOCTOU) remains
|
||||
Critical; the newly-discovered CR-02 (admin-demotion race via stale-member prop) joins it. WR-04
|
||||
is materially worse in the deep view — the stale `editorMember` is never updated from query data,
|
||||
making the Reset-to-pre-save regression reproducible on every session where a save is followed by
|
||||
Cancel. Two new issues are also added: WR-06 (profileMutation unconditionally sends displayName,
|
||||
blocking admin toggle on null-displayName members) and WR-07 (no-op {} PATCH crashes Drizzle
|
||||
with a 503 instead of a proper 400).
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Last-admin demotion guard is a non-atomic TOCTOU race
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:251-267`
|
||||
|
||||
**Issue:** The D-03 last-admin guard issues a `SELECT COUNT(*) WHERE is_admin=true` and only
|
||||
if the count is >1 proceeds to `UPDATE`. The SELECT and the UPDATE are not in a transaction.
|
||||
Two concurrent PATCH requests demoting the two existing admins both read `count=2`, both pass
|
||||
the guard, and both updates commit — leaving the household with zero admins. MariaDB's default
|
||||
InnoDB READ COMMITTED isolation does not prevent this: a phantom read between the COUNT and the
|
||||
UPDATE is possible even in REPEATABLE READ unless a locking read (`FOR UPDATE`/`FOR SHARE`) is
|
||||
used. The test suite (Test C and Test D) exercises the single-request path only; no concurrent
|
||||
scenario is tested.
|
||||
|
||||
```
|
||||
Thread 1: SELECT COUNT(*) WHERE is_admin=true → 2 → passes guard
|
||||
Thread 2: SELECT COUNT(*) WHERE is_admin=true → 2 → passes guard
|
||||
Thread 1: UPDATE users SET is_admin=false WHERE id=1 → ok
|
||||
Thread 2: UPDATE users SET is_admin=false WHERE id=2 → ok (now 0 admins)
|
||||
```
|
||||
|
||||
**Fix:** Wrap the full guard-plus-update in a transaction and use a locking read:
|
||||
|
||||
```typescript
|
||||
await db.transaction(async (tx) => {
|
||||
const [target] = await tx
|
||||
.select({ id: users.id, isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetId))
|
||||
.limit(1);
|
||||
if (!target) throw new Error('not-found');
|
||||
|
||||
if (isAdmin === false && target.isAdmin) {
|
||||
// Lock all admin rows before counting so concurrent demotions block each other
|
||||
const [{ count }] = await tx
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, true));
|
||||
// Note: add `.for('update')` when Drizzle exposes it, or use raw sql suffix
|
||||
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));
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, replace the SELECT/UPDATE pair with a single atomic conditional UPDATE and check
|
||||
`affectedRows`:
|
||||
|
||||
```sql
|
||||
UPDATE users
|
||||
SET is_admin = false
|
||||
WHERE id = :targetId
|
||||
AND (SELECT COUNT(*) FROM users u2 WHERE u2.is_admin = true) > 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CR-02: Stale `editorMember` in AdminPage means per-section save can silently overwrite a concurrent admin change
|
||||
|
||||
**File:** `apps/pwa/src/routes/AdminPage.tsx:83,243` / `apps/pwa/src/components/MemberEditorSheet.tsx:262-265`
|
||||
|
||||
**Issue:** `editorMember` is set once when the user taps a row (`openEditorForMember` at line 243)
|
||||
and is never refreshed from query data. `MemberEditorSheet` receives this as `member` and
|
||||
`profileMutation.mutationFn` (line 262-265) unconditionally sends both `displayName` and
|
||||
`isAdmin`:
|
||||
|
||||
```typescript
|
||||
await updateMemberProfile(member.id, {
|
||||
displayName: displayName.trim(),
|
||||
isAdmin, // ← always the value at sheet-open time, not query-refreshed
|
||||
});
|
||||
```
|
||||
|
||||
Scenario: Admin A opens the editor for Member X (isAdmin=false). Admin B (in another session)
|
||||
concurrently promotes Member X to admin. The server query cache eventually refetches and
|
||||
`membersQuery.data` shows `isAdmin=true`. But `editorMember` in AdminPage is still the stale
|
||||
object (`isAdmin=false`). Admin A's sheet still shows the toggle in the "off" position (because
|
||||
`useEffect` at line 234 re-syncs on `member?.isAdmin` change, but the `member` prop itself is
|
||||
never updated from the fresh query data — `editorMember` is the source and it never changes).
|
||||
Admin A clicks Save without touching the toggle → `isAdmin: false` is sent → Member X is silently
|
||||
demoted back to non-admin. No warning is shown. The profile-save toast reads "Profile saved."
|
||||
|
||||
This is the cross-file manifestation of WR-04 (stale closure) compounded by the fact that
|
||||
`editorMember` is never derived from `membersQuery.data`.
|
||||
|
||||
**Fix:** Derive the member prop from the live query data instead of holding a stale copy:
|
||||
|
||||
```typescript
|
||||
// In AdminPage:
|
||||
const editorMember = editorMemberId !== null
|
||||
? (membersQuery.data?.members.find((m) => m.id === editorMemberId) ?? null)
|
||||
: null;
|
||||
```
|
||||
|
||||
Replace `setEditorMember(member)` with `setEditorMemberId(member.id)`. This way, whenever
|
||||
`membersQuery.data` updates (e.g., after a save + invalidation), the derived `editorMember` is
|
||||
always fresh. The existing `useEffect` in `MemberEditorSheet` (line 234) already reacts to
|
||||
`member?.isAdmin` and `member?.displayName` changes, so the form state stays in sync
|
||||
automatically.
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Profile save always sends `displayName` even when only `isAdmin` changed; blocks admin toggle for null-displayName members
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:262-265, 568`
|
||||
|
||||
**Issue:** `profileMutation.mutationFn` always sends `{ displayName: displayName.trim(), isAdmin }`.
|
||||
Two distinct problems follow:
|
||||
|
||||
1. **Partial-update miss.** Every profile save re-writes the displayName even when the admin only
|
||||
toggled the admin flag. This doubles the blast radius of a profile save.
|
||||
|
||||
2. **Toggle blocked on null displayName.** The DB schema (`apps/api/src/db/schema.ts:52`)
|
||||
defines `display_name` as a nullable varchar (no `.notNull()`). An OIDC-provisioned user whose
|
||||
ID token had no `name` claim can have `displayName = null`. The editor initialises `displayName`
|
||||
state to `member?.displayName ?? ''` → `''`. The Save button is disabled when
|
||||
`displayName.trim().length === 0` (line 568), so the admin cannot toggle the admin flag for
|
||||
this member at all — the Save button remains permanently disabled with no explanatory copy.
|
||||
There is no empty-state message telling the admin they must add a name first.
|
||||
|
||||
**Fix — option A (preferred):** Send only changed fields:
|
||||
|
||||
```typescript
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
const payload: { displayName?: string; isAdmin?: boolean } = {};
|
||||
if (displayName.trim() !== (member.displayName ?? '')) {
|
||||
if (displayName.trim().length === 0) throw new Error('name-required');
|
||||
payload.displayName = displayName.trim();
|
||||
}
|
||||
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
|
||||
if (Object.keys(payload).length === 0) return; // no-op guard
|
||||
await updateMemberProfile(member.id, payload);
|
||||
},
|
||||
```
|
||||
|
||||
**Fix — option B (minimal):** Add an inline note when displayName is empty to explain why Save is
|
||||
disabled:
|
||||
|
||||
```tsx
|
||||
{displayName.trim().length === 0 && (
|
||||
<div style={inlineErrorStyle}>A display name is required before saving.</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-02: D-03 error revert uses `member?.isAdmin ?? true` — wrong default direction
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:276`
|
||||
|
||||
**Issue:** When the server returns 409 (last-admin guard), `onError` reverts the toggle with:
|
||||
|
||||
```typescript
|
||||
setIsAdmin(member?.isAdmin ?? true);
|
||||
```
|
||||
|
||||
The `?? true` default is semantically wrong. The guard fires only when the admin tries to
|
||||
demote the last admin, meaning the correct revert value is `true` (the member IS admin). However
|
||||
the `?? true` codifies this accidentally — if `member` were ever undefined here for another reason,
|
||||
any future mutation reuse could silently set `isAdmin=true` on an unrelated user. The mutation
|
||||
already guards `if (!member) throw new Error('no-member')` at line 261 so a missing `member` in
|
||||
the 409 path is structurally impossible today. The defect is that the code is correct only by
|
||||
coincidence, and the fallback `true` would be wrong if the same handler were reused to revert any
|
||||
*other* error that legitimately has `member=undefined`.
|
||||
|
||||
**Fix:** Capture the pre-mutation value at call time and carry it through context:
|
||||
|
||||
```typescript
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
await updateMemberProfile(member.id, { displayName: displayName.trim(), isAdmin });
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'last-admin') {
|
||||
// member is guaranteed non-null here (no-member throws before the API call)
|
||||
setIsAdmin(member!.isAdmin); // ← explicit, not ?? true
|
||||
setProfileError('Cannot remove admin — at least one admin must remain.');
|
||||
} else {
|
||||
setProfileError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-03: Phone bottom-sheet lacks `maxHeight`/`overflowY` — action buttons unreachable on short phones
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:379-392`
|
||||
|
||||
**Issue:** The desktop sheet style (lines 399-407) sets `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 + Set new password + App password), the content exceeds
|
||||
the viewport height on a 667px-tall iPhone SE. There is no scroll affordance; the "Save app
|
||||
password" button is unreachable without a way to scroll.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```typescript
|
||||
// phone branch of sheetStyle:
|
||||
{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '90dvh',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
```
|
||||
|
||||
This also resolves IN-03 (missing `env(safe-area-inset-bottom)`) in a single fix.
|
||||
|
||||
---
|
||||
|
||||
### WR-04: `handleClose` useCallback holds stale `member` fields — Cancel after per-section save resets to pre-save values
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:210-231`
|
||||
|
||||
**Issue:** `handleClose` is memoised:
|
||||
|
||||
```typescript
|
||||
const handleClose = useCallback(() => {
|
||||
setDisplayName(member?.displayName ?? ''); // ← captures member at memo creation time
|
||||
setIsAdmin(member?.isAdmin ?? false);
|
||||
...
|
||||
}, [onClose, triggerRef, member?.displayName, member?.isAdmin]);
|
||||
```
|
||||
|
||||
When `profileMutation.onSuccess` fires, it invalidates `['admin','members']`. The query refetches
|
||||
and `membersQuery.data` updates. BUT `editorMember` in `AdminPage` is never derived from
|
||||
`membersQuery.data` (confirmed by inspection — see CR-02). So `member?.displayName` in the
|
||||
dependency array still holds the pre-save value. `handleClose` correctly rebuilds when the dep
|
||||
changes in principle, but since `editorMember` never updates, the dep never changes.
|
||||
|
||||
Concretely: admin saves "New Name" → toast "Profile saved." → clicks Cancel → form resets to
|
||||
"Old Name". The next GET /api/admin/members will show the correct new name in the list row, but
|
||||
the sheet state that Cancel resets to is stale.
|
||||
|
||||
**Fix (preferred, pairs with CR-02 fix):** Once `editorMember` is derived from live query data
|
||||
(CR-02 fix), `member?.displayName` in the dep array will update after a save+refetch, and
|
||||
`handleClose` will capture the refreshed value. Separately, remove the redundant form-field
|
||||
resets from `handleClose` for member-sourced fields and let the existing `useEffect` (line 234)
|
||||
own that state:
|
||||
|
||||
```typescript
|
||||
const handleClose = useCallback(() => {
|
||||
// Only reset ephemeral fields (not member-derived: those belong to useEffect)
|
||||
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: Admin toggle missing `aria-describedby` for the last-admin error
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:515-537`
|
||||
|
||||
**Issue:** The display-name input has `aria-describedby={profileError ? 'profile-error' : undefined}`
|
||||
(line 479), correctly linking it to the shared error container at line 557. However the admin
|
||||
toggle button (line 515) has `aria-label="Admin"` but no `aria-describedby`. When the last-admin
|
||||
guard fires, `profileError` is set and the error `<div id="profile-error">` renders below the
|
||||
action buttons — but screen-reader users who activated the toggle have no announcement path from
|
||||
the toggle element to the error message.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isAdmin}
|
||||
aria-label="Admin"
|
||||
aria-describedby={profileError ? 'profile-error' : undefined}
|
||||
onClick={() => { setProfileError(null); setIsAdmin((prev) => !prev); }}
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-06: Empty `{}` PATCH body passes Zod but causes Drizzle to throw → returns 503 instead of 400
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:225-228, 261-275`
|
||||
|
||||
**Issue:** `updateMemberSchema` marks both fields optional:
|
||||
|
||||
```typescript
|
||||
const updateMemberSchema = z.object({
|
||||
displayName: z.string().min(1).max(256).optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
A client that sends `{}` passes Zod validation. Inside the handler, the `updates` object remains
|
||||
`{}` (lines 262-264, neither branch fires). `db.update(users).set({}).where(...)` is then called.
|
||||
In Drizzle ORM 0.45.x (mysql dialect) an empty `set({})` produces invalid SQL (`UPDATE users SET
|
||||
WHERE id = ?`) and the mysql2 driver throws a query error. The `catch` block at line 269 returns
|
||||
`503 Service unavailable` rather than a proper `400 Bad Request`. Callers receive an incorrect
|
||||
status that implies a transient server failure rather than a client error.
|
||||
|
||||
The existing client (`updateMemberProfile` in `client.ts`) always sends at least one field, so
|
||||
this path is unreachable from the UI today. It is reachable via direct API access.
|
||||
|
||||
**Fix:** Add a Zod refinement or an explicit 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 must be provided' },
|
||||
);
|
||||
```
|
||||
|
||||
This returns a 400 through the existing `noEchoHook` before the handler body runs.
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Profile-section "Save" in edit mode blocks admin-toggle saves when member has no Fastmail credential
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:567-575`
|
||||
|
||||
**Issue:** The Save button for Section 1 (Profile) is disabled when `displayName.trim().length === 0`.
|
||||
This is correct as a client-side guard, but there is no visible copy explaining *why* Save is
|
||||
disabled when the member's display name is null (a valid DB state for OIDC-provisioned users with
|
||||
no name claim). The button is greyed out and inert with no tooltip or inline copy. An admin who
|
||||
taps a member row and sees a greyed Save button for the admin toggle has no indication of what to
|
||||
do.
|
||||
|
||||
**Fix:** Render a short helper line when `displayName.trim().length === 0`:
|
||||
|
||||
```tsx
|
||||
{displayName.trim().length === 0 && (
|
||||
<p style={helperTextStyle}>Enter a display name to enable Save.</p>
|
||||
)}
|
||||
```
|
||||
|
||||
### IN-02: Display-name inputs lack `maxLength` — long entries get a generic server-side 400
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:474-481, 793-799`
|
||||
|
||||
**Issue:** Both the edit-mode display-name input (line 476) and the create-mode display-name input
|
||||
(line 795) have no `maxLength` attribute. The server schema enforces `max(256)` via Zod, but a
|
||||
client submission exceeding 256 characters returns a generic 400 (the `noEchoHook` maps all Zod
|
||||
failures to `{ error: 'Invalid request' }`) with no user-visible copy explaining the length limit.
|
||||
The edit-mode username input in create mode (line 805) similarly has no `maxLength={128}`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```tsx
|
||||
<input id="editor-display-name" type="text" maxLength={256} ... />
|
||||
<input id="create-display-name" type="text" maxLength={256} ... />
|
||||
<input id="create-username" type="text" maxLength={128} ... />
|
||||
```
|
||||
|
||||
### IN-03: Phone bottom-sheet does not account for `env(safe-area-inset-bottom)`
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:380-392`
|
||||
|
||||
**Issue:** The phone sheet style uses `bottom: 0` with no `paddingBottom` accounting for the iOS
|
||||
home-indicator / Android gesture-navigation bar. On a notched or edge-to-edge device, the Cancel
|
||||
and Save buttons in Section 1 (the first action row visible on open) may sit behind the system
|
||||
gesture bar. The `AdminPage` scroll container correctly uses
|
||||
`calc(56px + env(safe-area-inset-bottom, 0px))` (line 269) for its fixed tab-bar clearance, but
|
||||
the sheet itself does not.
|
||||
|
||||
**Fix:** Combined with WR-03 (add `maxHeight`/`overflowY` to the phone sheet), add bottom
|
||||
padding:
|
||||
|
||||
```typescript
|
||||
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-18_
|
||||
_Reviewer: Claude Sonnet 4.6 (gsd-code-reviewer, deep pass)_
|
||||
_Depth: deep_
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
phase: 20-admin-member-editor-form-declutter
|
||||
reviewed: 2026-06-18T12:00:00Z
|
||||
reviewed: 2026-06-18T14:30:00Z
|
||||
depth: deep
|
||||
files_reviewed: 5
|
||||
files_reviewed_list:
|
||||
@@ -10,448 +10,174 @@ files_reviewed_list:
|
||||
- apps/pwa/src/components/MemberEditorSheet.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 6
|
||||
info: 3
|
||||
total: 11
|
||||
status: issues_found
|
||||
critical: 0
|
||||
warning: 0
|
||||
info: 0
|
||||
total: 0
|
||||
status: clean
|
||||
---
|
||||
|
||||
# Phase 20: Code Review Report (Deep Re-Review)
|
||||
# Phase 20: Code Review Report (Deep Re-Review — Iteration 2)
|
||||
|
||||
**Reviewed:** 2026-06-18
|
||||
**Depth:** deep (cross-file, call-chain, state-machine analysis)
|
||||
**Files Reviewed:** 5
|
||||
**Status:** issues_found
|
||||
**Status:** clean
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 20 adds `PATCH /api/admin/members/:id` (displayName + isAdmin update), a unified
|
||||
`MemberEditorSheet` (edit/create modes), and a decluttered `AdminPage` member list. The
|
||||
authorization boundary (`requireAdmin` first on the router, live DB lookup every request) is
|
||||
sound and correctly inherited by all new routes. Password write-only discipline is preserved
|
||||
across the PATCH→client→sheet→AdminPage chain. The credential echo-protection pattern and the
|
||||
`noEchoHook` usage are consistent and correct.
|
||||
All 11 findings from the prior pass (2 Critical, 6 Warning, 3 Info) are genuinely resolved — not superficially patched. Verification traces are below.
|
||||
|
||||
This deep pass confirms all nine findings from the prior standard review (re-verified against the
|
||||
current code — none have been remediated). Two are re-classified: CR-01 (TOCTOU) remains
|
||||
Critical; the newly-discovered CR-02 (admin-demotion race via stale-member prop) joins it. WR-04
|
||||
is materially worse in the deep view — the stale `editorMember` is never updated from query data,
|
||||
making the Reset-to-pre-save regression reproducible on every session where a save is followed by
|
||||
Cancel. Two new issues are also added: WR-06 (profileMutation unconditionally sends displayName,
|
||||
blocking admin toggle on null-displayName members) and WR-07 (no-op {} PATCH crashes Drizzle
|
||||
with a 503 instead of a proper 400).
|
||||
One new Info-level issue was introduced by the no-op guard fix: the "Profile saved." toast fires even when the admin clicks Save without changing anything, because `mutationFn` returns early (no network call) but `onSuccess` still runs unconditionally.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
## Prior Finding Verification
|
||||
|
||||
### CR-01: Last-admin demotion guard is a non-atomic TOCTOU race
|
||||
### CR-01 — Last-admin guard now atomic: RESOLVED
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:251-267`
|
||||
`apps/api/src/routes/admin.ts:258–286`
|
||||
|
||||
**Issue:** The D-03 last-admin guard issues a `SELECT COUNT(*) WHERE is_admin=true` and only
|
||||
if the count is >1 proceeds to `UPDATE`. The SELECT and the UPDATE are not in a transaction.
|
||||
Two concurrent PATCH requests demoting the two existing admins both read `count=2`, both pass
|
||||
the guard, and both updates commit — leaving the household with zero admins. MariaDB's default
|
||||
InnoDB READ COMMITTED isolation does not prevent this: a phantom read between the COUNT and the
|
||||
UPDATE is possible even in REPEATABLE READ unless a locking read (`FOR UPDATE`/`FOR SHARE`) is
|
||||
used. The test suite (Test C and Test D) exercises the single-request path only; no concurrent
|
||||
scenario is tested.
|
||||
The guard and UPDATE are wrapped in a single `db.transaction()` call. Inside the transaction, the target row is re-read with a plain (non-locking) SELECT. The locking read is then a raw `tx.execute(sql\`SELECT COUNT(*) AS count FROM ... WHERE is_admin = true FOR UPDATE\`)`. Under InnoDB REPEATABLE READ (MariaDB default), `FOR UPDATE` acquires exclusive row locks on all qualifying rows, serialising concurrent demotion transactions: the second PATCH blocks until the first commits, then re-reads a count of 1 and trips the guard.
|
||||
|
||||
```
|
||||
Thread 1: SELECT COUNT(*) WHERE is_admin=true → 2 → passes guard
|
||||
Thread 2: SELECT COUNT(*) WHERE is_admin=true → 2 → passes guard
|
||||
Thread 1: UPDATE users SET is_admin=false WHERE id=1 → ok
|
||||
Thread 2: UPDATE users SET is_admin=false WHERE id=2 → ok (now 0 admins)
|
||||
```
|
||||
The `tx.execute()` call uses the transaction's dedicated connection (confirmed via drizzle-orm 0.45.2 `mysql2/session.js`: the transaction callback receives a `MySql2Transaction` whose session holds the connection obtained by `pool.getConnection()` — the same connection that issued `BEGIN`). The FOR UPDATE lock is therefore in-scope for the transaction.
|
||||
|
||||
**Fix:** Wrap the full guard-plus-update in a transaction and use a locking read:
|
||||
The COUNT result is destructured as `[[{ count }]]` from the raw execute result `[RowDataPacket[], FieldPacket[]]`. The cast is correct. `Number(count)` safely handles both `number` and `string` returns from MariaDB.
|
||||
|
||||
```typescript
|
||||
await db.transaction(async (tx) => {
|
||||
const [target] = await tx
|
||||
.select({ id: users.id, isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetId))
|
||||
.limit(1);
|
||||
if (!target) throw new Error('not-found');
|
||||
The 409 response shape `{ error: 'Cannot remove the last admin' }` is unchanged. The client (`client.ts:262`) maps 409 → `throw new Error('last-admin')`, and the sheet's `onError` checks `msg === 'last-admin'`. The chain is intact.
|
||||
|
||||
if (isAdmin === false && target.isAdmin) {
|
||||
// Lock all admin rows before counting so concurrent demotions block each other
|
||||
const [{ count }] = await tx
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, true));
|
||||
// Note: add `.for('update')` when Drizzle exposes it, or use raw sql suffix
|
||||
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));
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, replace the SELECT/UPDATE pair with a single atomic conditional UPDATE and check
|
||||
`affectedRows`:
|
||||
|
||||
```sql
|
||||
UPDATE users
|
||||
SET is_admin = false
|
||||
WHERE id = :targetId
|
||||
AND (SELECT COUNT(*) FROM users u2 WHERE u2.is_admin = true) > 1
|
||||
```
|
||||
Test C and Test D exercise the single-request guard paths and still pass. No concurrent-scenario test exists, but the fix is structurally correct and cannot be unit-tested against a single in-process MariaDB without intentional sleep-based race staging.
|
||||
|
||||
---
|
||||
|
||||
### CR-02: Stale `editorMember` in AdminPage means per-section save can silently overwrite a concurrent admin change
|
||||
### CR-02 — Stale editorMember: RESOLVED
|
||||
|
||||
**File:** `apps/pwa/src/routes/AdminPage.tsx:83,243` / `apps/pwa/src/components/MemberEditorSheet.tsx:262-265`
|
||||
`apps/pwa/src/routes/AdminPage.tsx:87, 122–127`
|
||||
|
||||
**Issue:** `editorMember` is set once when the user taps a row (`openEditorForMember` at line 243)
|
||||
and is never refreshed from query data. `MemberEditorSheet` receives this as `member` and
|
||||
`profileMutation.mutationFn` (line 262-265) unconditionally sends both `displayName` and
|
||||
`isAdmin`:
|
||||
`AdminPage` now stores only `editorMemberId: number | null` (line 87) and derives `editorMember` as a computed value on every render:
|
||||
|
||||
```typescript
|
||||
await updateMemberProfile(member.id, {
|
||||
displayName: displayName.trim(),
|
||||
isAdmin, // ← always the value at sheet-open time, not query-refreshed
|
||||
});
|
||||
const editorMember =
|
||||
editorMemberId !== null
|
||||
? (membersQuery.data?.members.find((m) => m.id === editorMemberId) ?? null)
|
||||
: null;
|
||||
```
|
||||
|
||||
Scenario: Admin A opens the editor for Member X (isAdmin=false). Admin B (in another session)
|
||||
concurrently promotes Member X to admin. The server query cache eventually refetches and
|
||||
`membersQuery.data` shows `isAdmin=true`. But `editorMember` in AdminPage is still the stale
|
||||
object (`isAdmin=false`). Admin A's sheet still shows the toggle in the "off" position (because
|
||||
`useEffect` at line 234 re-syncs on `member?.isAdmin` change, but the `member` prop itself is
|
||||
never updated from the fresh query data — `editorMember` is the source and it never changes).
|
||||
Admin A clicks Save without touching the toggle → `isAdmin: false` is sent → Member X is silently
|
||||
demoted back to non-admin. No warning is shown. The profile-save toast reads "Profile saved."
|
||||
|
||||
This is the cross-file manifestation of WR-04 (stale closure) compounded by the fact that
|
||||
`editorMember` is never derived from `membersQuery.data`.
|
||||
|
||||
**Fix:** Derive the member prop from the live query data instead of holding a stale copy:
|
||||
|
||||
```typescript
|
||||
// In AdminPage:
|
||||
const editorMember = editorMemberId !== null
|
||||
? (membersQuery.data?.members.find((m) => m.id === editorMemberId) ?? null)
|
||||
: null;
|
||||
```
|
||||
|
||||
Replace `setEditorMember(member)` with `setEditorMemberId(member.id)`. This way, whenever
|
||||
`membersQuery.data` updates (e.g., after a save + invalidation), the derived `editorMember` is
|
||||
always fresh. The existing `useEffect` in `MemberEditorSheet` (line 234) already reacts to
|
||||
`member?.isAdmin` and `member?.displayName` changes, so the form state stays in sync
|
||||
automatically.
|
||||
`openEditorForMember` calls `setEditorMemberId(member.id)` (line 254). After `profileMutation.onSuccess` invalidates `['admin', 'members']` and the query refetches, `editorMember` is rederived from fresh data on the next render. The `useEffect` in `MemberEditorSheet` (line 235–239) depends on `[member?.id, member?.displayName, member?.isAdmin]` and re-syncs form state immediately. The stale-snapshot overwrite path is closed.
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
### WR-01 — Profile mutation sends diff-only payload: RESOLVED
|
||||
|
||||
### WR-01: Profile save always sends `displayName` even when only `isAdmin` changed; blocks admin toggle for null-displayName members
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:261–275`
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:262-265, 568`
|
||||
|
||||
**Issue:** `profileMutation.mutationFn` always sends `{ displayName: displayName.trim(), isAdmin }`.
|
||||
Two distinct problems follow:
|
||||
|
||||
1. **Partial-update miss.** Every profile save re-writes the displayName even when the admin only
|
||||
toggled the admin flag. This doubles the blast radius of a profile save.
|
||||
|
||||
2. **Toggle blocked on null displayName.** The DB schema (`apps/api/src/db/schema.ts:52`)
|
||||
defines `display_name` as a nullable varchar (no `.notNull()`). An OIDC-provisioned user whose
|
||||
ID token had no `name` claim can have `displayName = null`. The editor initialises `displayName`
|
||||
state to `member?.displayName ?? ''` → `''`. The Save button is disabled when
|
||||
`displayName.trim().length === 0` (line 568), so the admin cannot toggle the admin flag for
|
||||
this member at all — the Save button remains permanently disabled with no explanatory copy.
|
||||
There is no empty-state message telling the admin they must add a name first.
|
||||
|
||||
**Fix — option A (preferred):** Send only changed fields:
|
||||
|
||||
```typescript
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
const payload: { displayName?: string; isAdmin?: boolean } = {};
|
||||
if (displayName.trim() !== (member.displayName ?? '')) {
|
||||
if (displayName.trim().length === 0) throw new Error('name-required');
|
||||
payload.displayName = displayName.trim();
|
||||
}
|
||||
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
|
||||
if (Object.keys(payload).length === 0) return; // no-op guard
|
||||
await updateMemberProfile(member.id, payload);
|
||||
},
|
||||
```
|
||||
|
||||
**Fix — option B (minimal):** Add an inline note when displayName is empty to explain why Save is
|
||||
disabled:
|
||||
|
||||
```tsx
|
||||
{displayName.trim().length === 0 && (
|
||||
<div style={inlineErrorStyle}>A display name is required before saving.</div>
|
||||
)}
|
||||
```
|
||||
`mutationFn` now builds a partial payload: `displayName` is added only when `trimmed !== (member.displayName ?? '')`, and `isAdmin` only when `isAdmin !== member.isAdmin`. An admin toggling only the admin flag on a null-displayName member sends `{ isAdmin: true/false }` with no `displayName` field — the Zod schema accepts this (both optional, refine requires at least one). The Save button remains enabled as long as `displayName.trim().length > 0` (or the existing displayName is non-null and unchanged). Toggle-only saves on null-displayName members are now unblocked.
|
||||
|
||||
---
|
||||
|
||||
### WR-02: D-03 error revert uses `member?.isAdmin ?? true` — wrong default direction
|
||||
### WR-02 — Error revert uses explicit value: RESOLVED
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:276`
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:290`
|
||||
|
||||
**Issue:** When the server returns 409 (last-admin guard), `onError` reverts the toggle with:
|
||||
|
||||
```typescript
|
||||
setIsAdmin(member?.isAdmin ?? true);
|
||||
```
|
||||
|
||||
The `?? true` default is semantically wrong. The guard fires only when the admin tries to
|
||||
demote the last admin, meaning the correct revert value is `true` (the member IS admin). However
|
||||
the `?? true` codifies this accidentally — if `member` were ever undefined here for another reason,
|
||||
any future mutation reuse could silently set `isAdmin=true` on an unrelated user. The mutation
|
||||
already guards `if (!member) throw new Error('no-member')` at line 261 so a missing `member` in
|
||||
the 409 path is structurally impossible today. The defect is that the code is correct only by
|
||||
coincidence, and the fallback `true` would be wrong if the same handler were reused to revert any
|
||||
*other* error that legitimately has `member=undefined`.
|
||||
|
||||
**Fix:** Capture the pre-mutation value at call time and carry it through context:
|
||||
|
||||
```typescript
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
await updateMemberProfile(member.id, { displayName: displayName.trim(), isAdmin });
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'last-admin') {
|
||||
// member is guaranteed non-null here (no-member throws before the API call)
|
||||
setIsAdmin(member!.isAdmin); // ← explicit, not ?? true
|
||||
setProfileError('Cannot remove admin — at least one admin must remain.');
|
||||
} else {
|
||||
setProfileError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
`setIsAdmin(member!.isAdmin)` replaces the accidental `?? true` default. The `member!` non-null assertion is safe here: `mutationFn` at line 262 throws `Error('no-member')` before any API call when `member` is undefined, so the 409 error path can only be reached with a non-null `member`. The revert is now semantically explicit.
|
||||
|
||||
---
|
||||
|
||||
### WR-03: Phone bottom-sheet lacks `maxHeight`/`overflowY` — action buttons unreachable on short phones
|
||||
### WR-03 — Phone bottom-sheet overflow: RESOLVED
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:379-392`
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:395–413`
|
||||
|
||||
**Issue:** The desktop sheet style (lines 399-407) sets `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 + Set new password + App password), the content exceeds
|
||||
the viewport height on a 667px-tall iPhone SE. There is no scroll affordance; the "Save app
|
||||
password" button is unreachable without a way to scroll.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```typescript
|
||||
// phone branch of sheetStyle:
|
||||
{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '90dvh',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
```
|
||||
|
||||
This also resolves IN-03 (missing `env(safe-area-inset-bottom)`) in a single fix.
|
||||
The phone branch of `sheetStyle` now has `maxHeight: '90dvh'` and `overflowY: 'auto'` (lines 404–405). All three sections scroll within the 90dvh cap on short phones.
|
||||
|
||||
---
|
||||
|
||||
### WR-04: `handleClose` useCallback holds stale `member` fields — Cancel after per-section save resets to pre-save values
|
||||
### WR-04 — handleClose stale closure: RESOLVED
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:210-231`
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:214–232`
|
||||
|
||||
**Issue:** `handleClose` is memoised:
|
||||
|
||||
```typescript
|
||||
const handleClose = useCallback(() => {
|
||||
setDisplayName(member?.displayName ?? ''); // ← captures member at memo creation time
|
||||
setIsAdmin(member?.isAdmin ?? false);
|
||||
...
|
||||
}, [onClose, triggerRef, member?.displayName, member?.isAdmin]);
|
||||
```
|
||||
|
||||
When `profileMutation.onSuccess` fires, it invalidates `['admin','members']`. The query refetches
|
||||
and `membersQuery.data` updates. BUT `editorMember` in `AdminPage` is never derived from
|
||||
`membersQuery.data` (confirmed by inspection — see CR-02). So `member?.displayName` in the
|
||||
dependency array still holds the pre-save value. `handleClose` correctly rebuilds when the dep
|
||||
changes in principle, but since `editorMember` never updates, the dep never changes.
|
||||
|
||||
Concretely: admin saves "New Name" → toast "Profile saved." → clicks Cancel → form resets to
|
||||
"Old Name". The next GET /api/admin/members will show the correct new name in the list row, but
|
||||
the sheet state that Cancel resets to is stale.
|
||||
|
||||
**Fix (preferred, pairs with CR-02 fix):** Once `editorMember` is derived from live query data
|
||||
(CR-02 fix), `member?.displayName` in the dep array will update after a save+refetch, and
|
||||
`handleClose` will capture the refreshed value. Separately, remove the redundant form-field
|
||||
resets from `handleClose` for member-sourced fields and let the existing `useEffect` (line 234)
|
||||
own that state:
|
||||
|
||||
```typescript
|
||||
const handleClose = useCallback(() => {
|
||||
// Only reset ephemeral fields (not member-derived: those belong to useEffect)
|
||||
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]);
|
||||
```
|
||||
`handleClose` dependency array is now `[onClose, triggerRef]` — it no longer captures `member?.displayName` or `member?.isAdmin`. Only ephemeral fields (password inputs, error states, create-mode fields) are reset in `handleClose`. Member-derived fields (`displayName`, `isAdmin`) are owned exclusively by the `useEffect` at lines 235–239, which fires whenever the live `member` prop changes. Cancel after a successful save now resets to the saved (fresh) values, not the pre-save snapshot.
|
||||
|
||||
---
|
||||
|
||||
### WR-05: Admin toggle missing `aria-describedby` for the last-admin error
|
||||
### WR-05 — Admin toggle aria-describedby: RESOLVED
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:515-537`
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:544`
|
||||
|
||||
**Issue:** The display-name input has `aria-describedby={profileError ? 'profile-error' : undefined}`
|
||||
(line 479), correctly linking it to the shared error container at line 557. However the admin
|
||||
toggle button (line 515) has `aria-label="Admin"` but no `aria-describedby`. When the last-admin
|
||||
guard fires, `profileError` is set and the error `<div id="profile-error">` renders below the
|
||||
action buttons — but screen-reader users who activated the toggle have no announcement path from
|
||||
the toggle element to the error message.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isAdmin}
|
||||
aria-label="Admin"
|
||||
aria-describedby={profileError ? 'profile-error' : undefined}
|
||||
onClick={() => { setProfileError(null); setIsAdmin((prev) => !prev); }}
|
||||
...
|
||||
>
|
||||
```
|
||||
The admin toggle `<button>` now has `aria-describedby={profileError ? 'profile-error' : undefined}`, linking it to the shared `<div id="profile-error">` error container (line 588). Screen-reader users who activated the toggle receive an announcement path to the last-admin guard error.
|
||||
|
||||
---
|
||||
|
||||
### WR-06: Empty `{}` PATCH body passes Zod but causes Drizzle to throw → returns 503 instead of 400
|
||||
### WR-06 — Empty {} PATCH returns 400: RESOLVED
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:225-228, 261-275`
|
||||
`apps/api/src/routes/admin.ts:225–232`
|
||||
|
||||
**Issue:** `updateMemberSchema` marks both fields optional:
|
||||
`updateMemberSchema` now has a `.refine()` that rejects any body where both `displayName` and `isAdmin` are absent. The `noEchoHook` returns `{ error: 'Invalid request' }` 400 before the handler body executes. Drizzle is never called with an empty `set({})`.
|
||||
|
||||
```typescript
|
||||
const updateMemberSchema = z.object({
|
||||
displayName: z.string().min(1).max(256).optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
});
|
||||
```
|
||||
Test H (line 1231–1241 in `admin.test.ts`) asserts this path returns 400 with `{ error: 'Invalid request' }`.
|
||||
|
||||
A client that sends `{}` passes Zod validation. Inside the handler, the `updates` object remains
|
||||
`{}` (lines 262-264, neither branch fires). `db.update(users).set({}).where(...)` is then called.
|
||||
In Drizzle ORM 0.45.x (mysql dialect) an empty `set({})` produces invalid SQL (`UPDATE users SET
|
||||
WHERE id = ?`) and the mysql2 driver throws a query error. The `catch` block at line 269 returns
|
||||
`503 Service unavailable` rather than a proper `400 Bad Request`. Callers receive an incorrect
|
||||
status that implies a transient server failure rather than a client error.
|
||||
---
|
||||
|
||||
The existing client (`updateMemberProfile` in `client.ts`) always sends at least one field, so
|
||||
this path is unreachable from the UI today. It is reachable via direct API access.
|
||||
### IN-01 — Helper text for empty displayName: RESOLVED
|
||||
|
||||
**Fix:** Add a Zod refinement or an explicit pre-flight check:
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:580–584`
|
||||
|
||||
```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 must be provided' },
|
||||
);
|
||||
```
|
||||
The helper text "Enter a display name to enable Save." renders when `displayName.trim().length === 0 && !profileError`. Admins opening a null-displayName member's editor now see an explanation for why the Save button is disabled.
|
||||
|
||||
This returns a 400 through the existing `noEchoHook` before the handler body runs.
|
||||
---
|
||||
|
||||
### IN-02 — maxLength on display-name and username inputs: RESOLVED
|
||||
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:500, 826, 842`
|
||||
|
||||
All three inputs now have `maxLength`: edit-mode display-name `maxLength={256}` (line 500), create-mode display-name `maxLength={256}` (line 826), create-mode username `maxLength={128}` (line 842). Over-length submissions are prevented at the browser input level.
|
||||
|
||||
---
|
||||
|
||||
### IN-03 — Phone sheet safe-area padding: RESOLVED
|
||||
|
||||
`apps/pwa/src/components/MemberEditorSheet.tsx:411`
|
||||
|
||||
`paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))'` is present in the phone branch, co-located with the `maxHeight`/`overflowY` fix from WR-03.
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Profile-section "Save" in edit mode blocks admin-toggle saves when member has no Fastmail credential
|
||||
### IN-01: No-op profile save fires misleading "Profile saved." toast
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:567-575`
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:274, 277–280`
|
||||
|
||||
**Issue:** The Save button for Section 1 (Profile) is disabled when `displayName.trim().length === 0`.
|
||||
This is correct as a client-side guard, but there is no visible copy explaining *why* Save is
|
||||
disabled when the member's display name is null (a valid DB state for OIDC-provisioned users with
|
||||
no name claim). The button is greyed out and inert with no tooltip or inline copy. An admin who
|
||||
taps a member row and sees a greyed Save button for the admin toggle has no indication of what to
|
||||
do.
|
||||
**Issue:** When the admin opens the editor and clicks Save without making any changes, `mutationFn` detects an empty payload (`Object.keys(payload).length === 0`) and returns early without calling the API. TanStack Query v5 treats a non-throwing return as a successful mutation and calls `onSuccess`, which fires `invalidateQueries(['admin', 'members'])` and `onToast('Profile saved.')`. The admin sees a confirmation toast for an action that sent nothing. The query also refetches unnecessarily.
|
||||
|
||||
**Fix:** Render a short helper line when `displayName.trim().length === 0`:
|
||||
This cannot be reached through the empty-displayName path (Save is disabled then), but it is reachable any time an admin opens a sheet and saves without touching anything.
|
||||
|
||||
```tsx
|
||||
{displayName.trim().length === 0 && (
|
||||
<p style={helperTextStyle}>Enter a display name to enable Save.</p>
|
||||
)}
|
||||
```
|
||||
|
||||
### IN-02: Display-name inputs lack `maxLength` — long entries get a generic server-side 400
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:474-481, 793-799`
|
||||
|
||||
**Issue:** Both the edit-mode display-name input (line 476) and the create-mode display-name input
|
||||
(line 795) have no `maxLength` attribute. The server schema enforces `max(256)` via Zod, but a
|
||||
client submission exceeding 256 characters returns a generic 400 (the `noEchoHook` maps all Zod
|
||||
failures to `{ error: 'Invalid request' }`) with no user-visible copy explaining the length limit.
|
||||
The edit-mode username input in create mode (line 805) similarly has no `maxLength={128}`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```tsx
|
||||
<input id="editor-display-name" type="text" maxLength={256} ... />
|
||||
<input id="create-display-name" type="text" maxLength={256} ... />
|
||||
<input id="create-username" type="text" maxLength={128} ... />
|
||||
```
|
||||
|
||||
### IN-03: Phone bottom-sheet does not account for `env(safe-area-inset-bottom)`
|
||||
|
||||
**File:** `apps/pwa/src/components/MemberEditorSheet.tsx:380-392`
|
||||
|
||||
**Issue:** The phone sheet style uses `bottom: 0` with no `paddingBottom` accounting for the iOS
|
||||
home-indicator / Android gesture-navigation bar. On a notched or edge-to-edge device, the Cancel
|
||||
and Save buttons in Section 1 (the first action row visible on open) may sit behind the system
|
||||
gesture bar. The `AdminPage` scroll container correctly uses
|
||||
`calc(56px + env(safe-area-inset-bottom, 0px))` (line 269) for its fixed tab-bar clearance, but
|
||||
the sheet itself does not.
|
||||
|
||||
**Fix:** Combined with WR-03 (add `maxHeight`/`overflowY` to the phone sheet), add bottom
|
||||
padding:
|
||||
**Fix:** Guard the toast and invalidation on whether a payload was actually sent:
|
||||
|
||||
```typescript
|
||||
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
const payload: { displayName?: string; isAdmin?: boolean } = {};
|
||||
const trimmed = displayName.trim();
|
||||
if (trimmed !== (member.displayName ?? '')) {
|
||||
if (trimmed.length === 0) throw new Error('name-required');
|
||||
payload.displayName = trimmed;
|
||||
}
|
||||
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
|
||||
if (Object.keys(payload).length === 0) return { noop: true };
|
||||
await updateMemberProfile(member.id, payload);
|
||||
return { noop: false };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result?.noop) return; // nothing changed — no toast, no refetch
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
onToast('Profile saved.');
|
||||
},
|
||||
```
|
||||
|
||||
Alternatively, disable the Save button when `displayName.trim() === (member?.displayName ?? '')` and `isAdmin === member?.isAdmin` (change-detection guard on the button itself).
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-18_
|
||||
_Reviewer: Claude Sonnet 4.6 (gsd-code-reviewer, deep pass)_
|
||||
_Reviewer: Claude Sonnet 4.6 (gsd-code-reviewer, deep pass — iteration 2)_
|
||||
_Depth: deep_
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
phase: 20
|
||||
slug: admin-member-editor-form-declutter
|
||||
status: verified
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: 2026-06-18
|
||||
---
|
||||
|
||||
# Phase 20 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
> Result: **SECURED** — 10/10 threats CLOSED. `register_authored_at_plan_time: true` (verify-only; no new-threat scan).
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| client → /api/admin | Untrusted admin-session input crosses into the admin surface; guarded by router-wide `requireAdmin` (`admin.ts:48`). No NEW boundary added by this phase. | Member profile fields, `isAdmin` toggle, app-password credential |
|
||||
| PWA → /api/admin | Client fetch (`updateMemberProfile`, `saveCredential`) calls into the admin surface; server-side `requireAdmin` + last-admin guard are the real boundaries. Client toggle state is non-authoritative. | Same as above; password fields are write-only |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|-------------|------------|--------|
|
||||
| T-20-01 | Elevation of Privilege | PATCH /members/:id isAdmin toggle | mitigate | Router-wide `requireAdmin` first statement (`admin.ts:48`); PATCH adds no second/weaker guard (`:234`); 403 test (`admin.test.ts:1180-1190`) | closed |
|
||||
| T-20-02 | Denial of Service (self-lockout) | last-admin demotion | mitigate | Last-admin 409 guard, count+update in txn with `FOR UPDATE` (`admin.ts:271-293`); 409 test (`:1128-1150`), self-demote-with-2nd-admin 200 test (`:1153-1177`) | closed |
|
||||
| T-20-03 | Tampering | malformed :id / wrong-type body | mitigate | `parsePositiveIntParam` rejects bad ids → 400 (`admin.ts:88-93,235`); `updateMemberSchema` + `noEchoHook` reject wrong types → 400 (`:225-234,76`); test F (`:1193-1216`) | closed |
|
||||
| T-20-04 | Information Disclosure | error echo on invalid input | mitigate | `noEchoHook` returns only `{ error: 'Invalid request' }` (`admin.ts:76-80`); request body never logged (`:241,297-300`) | closed |
|
||||
| T-20-05 | Spoofing (stale session) | updateMemberProfile fetch | mitigate | `SessionExpiredError` on 401/opaqueredirect reuses existing re-auth flow (`client.ts:261`); `redirect:'manual'` + `credentials:'include'` (`:254-258`) | closed |
|
||||
| T-20-06 | Elevation of Privilege (client trust) | last-admin sentinel | accept | Server 409 authoritative (`admin.ts:271-293`); client only surfaces `'last-admin'` sentinel (`client.ts:262`). See Accepted Risks Log. | closed |
|
||||
| T-20-07 | Information Disclosure | password / app-password fields | mitigate | Fields write-only: `type=password` + `autoComplete="new-password"`, blank init (`MemberEditorSheet.tsx:188-205,639-882`); no `console.*` logging (0 grep matches) | closed |
|
||||
| T-20-08 | Tampering | CalDAV credential | mitigate | App-password save routes through `saveCredential` → server-side CalDAV validation before store (`MemberEditorSheet.tsx:338`, `admin.ts:373-393`); invalid → failure copy, nothing stored (`:355`) | closed |
|
||||
| T-20-09 | Elevation of Privilege (UI bypass) | admin toggle | mitigate | Toggle cosmetic `role=switch` (`MemberEditorSheet.tsx:546`); reverts + inline error on `last-admin` (`:288-295`); real enforcement is server 409 (T-20-02) | closed |
|
||||
| T-20-SC | Tampering (supply chain) | npm installs | mitigate | No new packages; `lucide-react@1.17.0` already in `package.json:30`; imports `AdminPage.tsx:28`, `MemberEditorSheet.tsx:25-27` | closed |
|
||||
|
||||
*Status: open · closed*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-20-01 | T-20-06 | Client-side last-admin toggle state is non-authoritative by design. The demotion guard is enforced server-side (409, `admin.ts:271-293`); the client only surfaces the rejection via the `'last-admin'` sentinel (`client.ts:262`) and reverts the toggle (`MemberEditorSheet.tsx:294`). A tampered client that ignores the sentinel still cannot bypass the guard — the server rejects regardless. Matches existing pattern: "isAdmin drives nav visibility; the real boundary is server-side." Residual risk: none beyond the already-mitigated server boundary at ASVS L1. | Lucas Berger (per Plan 20-02 threat model) | 2026-06-18 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-06-18 | 10 | 10 | 0 | gsd-security-auditor |
|
||||
|
||||
**Notable hardening beyond plan:** the last-admin guard wraps count+update in a transaction with a `FOR UPDATE` locking read (`admin.ts:249-286`) to defeat a concurrent double-demotion race — strengthens T-20-02 past the plan minimum.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-06-18
|
||||
@@ -1234,9 +1234,7 @@ describe('PATCH /api/admin/members/:id', () => {
|
||||
currentDevUserId = adminId;
|
||||
const app = await getApp();
|
||||
|
||||
const res = await app.fetch(
|
||||
jsonRequest('PATCH', `/api/admin/members/${memberId}`, {}),
|
||||
);
|
||||
const res = await app.fetch(jsonRequest('PATCH', `/api/admin/members/${memberId}`, {}));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Invalid request');
|
||||
|
||||
@@ -207,10 +207,11 @@ export function MemberEditorSheet({
|
||||
|
||||
// ── handleClose ──────────────────────────────────────────────────────────
|
||||
|
||||
// WR-04: only reset ephemeral fields (password inputs, error states, create-mode
|
||||
// fields). Member-derived fields (displayName, isAdmin) are owned by the useEffect
|
||||
// below and will re-sync from the live member prop when the sheet re-opens or when
|
||||
// membersQuery refetches — no stale closure problem.
|
||||
const handleClose = useCallback(() => {
|
||||
// Reset all form state
|
||||
setDisplayName(member?.displayName ?? '');
|
||||
setIsAdmin(member?.isAdmin ?? false);
|
||||
setProfileError(null);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
@@ -228,7 +229,7 @@ export function MemberEditorSheet({
|
||||
if (triggerRef?.current) {
|
||||
triggerRef.current.focus();
|
||||
}
|
||||
}, [onClose, triggerRef, member?.displayName, member?.isAdmin]);
|
||||
}, [onClose, triggerRef]);
|
||||
|
||||
// Sync profile state when member changes (different row opened)
|
||||
useEffect(() => {
|
||||
@@ -259,12 +260,25 @@ export function MemberEditorSheet({
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
await updateMemberProfile(member.id, {
|
||||
displayName: displayName.trim(),
|
||||
isAdmin,
|
||||
});
|
||||
// WR-01: send only the fields that actually changed to avoid re-writing
|
||||
// displayName on an admin-toggle-only save (and to prevent 400s when a member
|
||||
// has a null displayName and the admin only wants to toggle the admin flag).
|
||||
const payload: { displayName?: string; isAdmin?: boolean } = {};
|
||||
const trimmed = displayName.trim();
|
||||
if (trimmed !== (member.displayName ?? '')) {
|
||||
if (trimmed.length === 0) throw new Error('name-required');
|
||||
payload.displayName = trimmed;
|
||||
}
|
||||
if (isAdmin !== member.isAdmin) payload.isAdmin = isAdmin;
|
||||
// No-op guard — nothing changed, skip the network call and signal no write
|
||||
// (IN-04: avoids firing the "Profile saved." toast + refetch on a no-op save).
|
||||
if (Object.keys(payload).length === 0) return false;
|
||||
await updateMemberProfile(member.id, payload);
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (changed) => {
|
||||
// IN-04: only surface success feedback when an actual write occurred.
|
||||
if (!changed) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
onToast('Profile saved.');
|
||||
// Sheet stays open — per-section save (D-05)
|
||||
@@ -272,9 +286,15 @@ export function MemberEditorSheet({
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'last-admin') {
|
||||
// D-03 guard: revert toggle to previous value (member.isAdmin was true)
|
||||
setIsAdmin(member?.isAdmin ?? true);
|
||||
// D-03 guard: revert toggle to the actual prior value on the member object.
|
||||
// WR-02: use member!.isAdmin explicitly rather than `?? true` — the `?? true`
|
||||
// was accidentally correct only because the guard fires when demoting an admin,
|
||||
// but it would incorrectly set isAdmin=true for any future error path where
|
||||
// member is non-null but isAdmin is false.
|
||||
setIsAdmin(member!.isAdmin);
|
||||
setProfileError('Cannot remove admin — at least one admin must remain.');
|
||||
} else if (msg === 'name-required') {
|
||||
setProfileError('A display name is required before saving.');
|
||||
} else {
|
||||
setProfileError('Something went wrong. Please try again.');
|
||||
}
|
||||
@@ -382,10 +402,17 @@ export function MemberEditorSheet({
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// WR-03: cap height so content does not spill off screen on short phones
|
||||
// (iPhone SE 667px with three sections visible). overflowY:'auto' enables
|
||||
// scroll when content exceeds maxHeight.
|
||||
maxHeight: '90dvh',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
// IN-03: pad the bottom to clear iOS home indicator / Android gesture bar
|
||||
paddingBottom: 'calc(var(--space-6, 24px) + env(safe-area-inset-bottom, 0px))',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}
|
||||
@@ -474,6 +501,7 @@ export function MemberEditorSheet({
|
||||
<input
|
||||
id="editor-display-name"
|
||||
type="text"
|
||||
maxLength={256}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
aria-describedby={profileError ? 'profile-error' : undefined}
|
||||
@@ -517,6 +545,7 @@ export function MemberEditorSheet({
|
||||
role="switch"
|
||||
aria-checked={isAdmin}
|
||||
aria-label="Admin"
|
||||
aria-describedby={profileError ? 'profile-error' : undefined}
|
||||
onClick={() => {
|
||||
setProfileError(null);
|
||||
setIsAdmin((prev) => !prev);
|
||||
@@ -552,6 +581,12 @@ export function MemberEditorSheet({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* IN-01: helper text when display name is empty (e.g. null-displayName
|
||||
OIDC member) so the admin knows why Save is disabled */}
|
||||
{displayName.trim().length === 0 && !profileError && (
|
||||
<p style={helperTextStyle}>Enter a display name to enable Save.</p>
|
||||
)}
|
||||
|
||||
{/* Last-admin guard inline error */}
|
||||
{profileError && (
|
||||
<div id="profile-error" style={inlineErrorStyle}>
|
||||
@@ -792,6 +827,7 @@ export function MemberEditorSheet({
|
||||
<input
|
||||
id="create-display-name"
|
||||
type="text"
|
||||
maxLength={256}
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
aria-describedby={createError ? 'create-error' : undefined}
|
||||
@@ -807,6 +843,7 @@ export function MemberEditorSheet({
|
||||
<input
|
||||
id="create-username"
|
||||
type="text"
|
||||
maxLength={128}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
|
||||
Reference in New Issue
Block a user