From 9e6b00454195b4d1ab8498f217540af3cb11ca2d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:35:37 -0400 Subject: [PATCH] feat(20-03): rework AdminPage MemberRow + Add-member trigger; remove old surfaces - MemberRow: whole-row role=button, aria-label='Edit {name}', trailing ChevronRight - Admin badge on member.isAdmin (12px/600, --color-member-0 on --color-surface-dim) - 'Add member' ghost trigger with Plus icon opens MemberEditorSheet in create mode - Single MemberEditorSheet replaces CredentialSheet + ResetPasswordSheet mounts - Removed: inline Local Accounts add-form, ResetPasswordSheet definition, create-form state - Empty state: 'No members yet' / 'Add a member to get started.' (UI-SPEC copy) --- apps/pwa/src/routes/AdminPage.tsx | 826 +++++------------------------- 1 file changed, 140 insertions(+), 686 deletions(-) diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index c953fcd..952eb24 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -9,10 +9,10 @@ * - Centered content column, maxWidth 640px on desktop * - var(--space-12) top/bottom padding, var(--space-6) horizontal padding * - * UI-SPEC §Surface 2 (MEMBERS section): - * - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge - * - "Rotate" or "Add credential" action button per hasCredential - * - Opens CredentialSheet in admin-rotate or admin-add mode + * UI-SPEC §Surface A (MEMBERS section — Phase 20): + * - Tappable MemberRow with trailing ChevronRight + "Admin" badge when isAdmin + * - Single "Add member" ghost trigger at the bottom (opens MemberEditorSheet in create mode) + * - No per-row action-button cluster; no always-open inline add form * * UI-SPEC §Surface 5 (SHARED CALENDAR section): * - Radio group, one row per synced calendar @@ -25,21 +25,18 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import { CheckCircle, AlertCircle, ChevronRight, Plus } from 'lucide-react'; import { fetchAdminMembers, fetchAdminCalendars, setSharedCalendar, fetchAdminTimezone, setAdminTimezone, - fetchCreateMember, - fetchAdminResetPassword, type AdminMember, type AdminCalendar, } from '../api/client.js'; -import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js'; +import { MemberEditorSheet } from '../components/MemberEditorSheet.js'; import { useIsPhone } from '../hooks/useIsPhone.js'; -import { useFocusTrap } from '../hooks/useFocusTrap.js'; // ── Styles ───────────────────────────────────────────────────────────────── @@ -78,24 +75,16 @@ export function AdminPage() { // Two-tab navigation state (D-10) const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members'); - // Credential sheet state - const [sheetOpen, setSheetOpen] = useState(false); - const [sheetMode, setSheetMode] = useState('admin-add'); - const [sheetMember, setSheetMember] = useState(null); - const triggerRef = useRef(null); - - // Reset-password sheet state (Surface 11B) - const [resetSheetOpen, setResetSheetOpen] = useState(false); - const [resetTargetMember, setResetTargetMember] = useState(null); - // resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close - const resetTriggerRef = useRef(null); - - // Create-member form state (Surface 11A) - const [createDisplayName, setCreateDisplayName] = useState(''); - const [createUsername, setCreateUsername] = useState(''); - const [createPassword, setCreatePassword] = useState(''); - const [createConfirmPassword, setCreateConfirmPassword] = useState(''); - const [createError, setCreateError] = useState(null); + // ── Member editor sheet state ─────────────────────────────────────────── + // A single MemberEditorSheet instance handles both edit (row tap) and + // create ("Add member" trigger) modes (D-07 unification). + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<'edit' | 'create'>('edit'); + const [editorMember, setEditorMember] = useState(null); + // Ref to the element that opened the sheet — focus returns here on close (a11y) + const editorTriggerRef = useRef(null); + // Ref for the "Add member" ghost trigger button + const addMemberTriggerRef = useRef(null); // Shared calendar picker state const [selectedCalendarId, setSelectedCalendarId] = useState(null); @@ -248,62 +237,21 @@ export function AdminPage() { )?.focus(); } - // Open credential sheet for a member - function openSheet(member: AdminMember, buttonRef: React.RefObject) { - // Capture the button so focus can return on close - (triggerRef as React.MutableRefObject).current = buttonRef.current; - setSheetMember(member); - setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add'); - setSheetOpen(true); + // Open the editor for a specific member (row tap → edit mode) + function openEditorForMember(member: AdminMember, rowEl: HTMLElement) { + editorTriggerRef.current = rowEl; + setEditorMember(member); + setEditorMode('edit'); + setEditorOpen(true); } - // Create-member mutation (Surface 11A) - const createMemberMutation = useMutation({ - mutationFn: async () => { - // Client-side validation (server also validates; this is for UX) - if (createPassword !== createConfirmPassword) { - throw new Error('mismatch'); - } - if (createPassword.length < 8) { - throw new Error('short'); - } - await fetchCreateMember({ - displayName: createDisplayName.trim(), - username: createUsername.trim(), - password: createPassword, - }); - }, - onSuccess: () => { - // Clear form + refresh member list - setCreateDisplayName(''); - setCreateUsername(''); - setCreatePassword(''); - setCreateConfirmPassword(''); - setCreateError(null); - void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); - void queryClient.invalidateQueries({ queryKey: ['me'] }); - showToast('Member added.'); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : 'server'; - if (msg === 'mismatch') { - setCreateError('Passwords do not match.'); - } else if (msg === 'short') { - setCreateError('Password is too short. Use at least 8 characters.'); - } else if (msg === 'conflict' || msg.includes('409')) { - setCreateError('That username is already in use. Choose a different one.'); - } else { - setCreateError('Something went wrong. Please try again.'); - } - }, - }); - - const createSubmitDisabled = - createMemberMutation.isPending || - createDisplayName.trim().length === 0 || - createUsername.trim().length === 0 || - createPassword.length === 0 || - createConfirmPassword.length === 0; + // Open the editor in create mode ("Add member" trigger) + function openEditorForCreate() { + editorTriggerRef.current = addMemberTriggerRef.current; + setEditorMember(null); + setEditorMode('create'); + setEditorOpen(true); + } const saveDisabled = sharedCalMutation.isPending || @@ -416,248 +364,79 @@ export function AdminPage() { )} - {membersQuery.data && ( + {membersQuery.data && membersQuery.data.members.length === 0 && ( +
+
+ No members yet +
+
+ Add a member to get started. +
+
+ )} + + {membersQuery.data && membersQuery.data.members.length > 0 && (
{membersQuery.data.members.map((member, idx) => ( openSheet(member, buttonRef)} - onResetPassword={(buttonRef) => { - // Capture trigger button so focus can return on close - resetTriggerRef.current = buttonRef.current; - setResetTargetMember(member); - setResetSheetOpen(true); - }} + onEdit={(rowEl) => openEditorForMember(member, rowEl)} /> ))}
)} - - {/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */} -
-
Local Accounts
- - {/* Surface 11A — Add member inline form */} -
-
+
- - {/* Display name */} -
- - setCreateDisplayName(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Username */} -
- - setCreateUsername(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Initial password */} -
- - setCreatePassword(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Confirm password */} -
- - setCreateConfirmPassword(e.target.value)} - style={{ - width: '100%', - boxSizing: 'border-box', - padding: 'var(--space-3, 12px) var(--space-4, 16px)', - border: '1px solid var(--color-border)', - borderRadius: 'var(--space-1, 4px)', - fontSize: 'var(--text-body-size, 15px)', - color: 'var(--color-text-primary)', - background: 'var(--color-surface)', - fontFamily: 'var(--font-family-base)', - outline: 'none', - minHeight: '44px', - }} - /> -
- - {/* Inline error */} - {createError && ( -
- {createError} -
- )} - - {/* Action row */} -
- -
+
@@ -1108,33 +887,15 @@ export function AdminPage() { )} - {/* Credential sheet — admin-rotate or admin-add */} - {sheetMember && ( - setSheetOpen(false)} - mode={sheetMode} - memberName={sheetMember.displayName} - memberId={sheetMember.id} - triggerRef={triggerRef} - /> - )} - - {/* Surface 11B — Reset password sheet */} - {resetTargetMember && ( - { - setResetSheetOpen(false); - // Return focus to trigger - if (resetTriggerRef.current) { - resetTriggerRef.current.focus(); - } - }} - onSuccess={() => showToast('Password reset.')} - member={resetTargetMember} - /> - )} + {/* Single unified MemberEditorSheet — handles edit + create modes (D-07) */} + setEditorOpen(false)} + mode={editorMode} + member={editorMember ?? undefined} + triggerRef={editorTriggerRef} + onToast={showToast} + /> ); } @@ -1144,16 +905,37 @@ export function AdminPage() { interface MemberRowProps { member: AdminMember; colorIndex: number; - onAction: (buttonRef: React.RefObject) => void; - onResetPassword?: (buttonRef: React.RefObject) => void; + /** Called with the row element so focus can return to it on editor close */ + onEdit: (rowEl: HTMLElement) => void; } -function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) { - const buttonRef = useRef(null); - const resetBtnRef = useRef(null); +/** + * Tappable member row (D-04): whole-row role="button" target with trailing + * ChevronRight affordance. Opens MemberEditorSheet in edit mode on tap/click + * or Enter/Space. Admin badge displayed when member.isAdmin. + */ +function MemberRow({ member, colorIndex, onEdit }: MemberRowProps) { + const rowRef = useRef(null); + + function handleActivate() { + if (rowRef.current) { + onEdit(rowRef.current); + } + } return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivate(); + } + }} style={{ display: 'flex', alignItems: 'center', @@ -1161,6 +943,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP minHeight: '44px', padding: 'var(--space-2, 8px) 0', borderBottom: '1px solid var(--color-border-subtle, var(--color-border))', + cursor: 'pointer', }} > {/* Avatar swatch */} @@ -1175,7 +958,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP }} /> - {/* Name + status */} + {/* Name + credential status badge */}
- {/* Action button row */} -
- {/* Credential rotate/add button */} - + Admin + + )} - {/* Surface 11B — Reset password button (only for members with a local credential) */} - {member.hasLocalCredential && onResetPassword && ( - - )} -
+ {/* Trailing chevron affordance */} +
); } @@ -1372,310 +1130,6 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr ); } -// ── ResetPasswordSheet ────────────────────────────────────────────────────── - -/** - * Surface 11B — Admin password reset sheet. - * Opens as a bottom sheet (mobile) / centered modal (desktop). - * Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger). - * No current-password field — admin reset does not require knowing the old password. - */ - -interface ResetPasswordSheetProps { - isOpen: boolean; - onClose: () => void; - onSuccess?: () => void; - member: AdminMember; -} - -function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) { - // WR-05: resize-aware phone detection. - const sheetPhone = useIsPhone(); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [error, setError] = useState(null); - const headingRef = useRef(null); - // WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true"). - const dialogRef = useRef(null); - const handleDialogKeyDown = useFocusTrap(dialogRef); - - // Escape closes the sheet - useEffect(() => { - if (!isOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [isOpen, onClose]); - - // Focus heading on open - useEffect(() => { - if (isOpen && headingRef.current) { - headingRef.current.focus(); - } - }, [isOpen]); - - function handleClose() { - setNewPassword(''); - setConfirmPassword(''); - setError(null); - onClose(); - } - - const resetMutation = useMutation({ - mutationFn: async () => { - if (newPassword !== confirmPassword) throw new Error('mismatch'); - await fetchAdminResetPassword(member.id, newPassword); - }, - onSuccess: () => { - handleClose(); - onSuccess?.(); - }, - onError: (err) => { - const msg = err instanceof Error ? err.message : 'server'; - if (msg === 'mismatch') { - setError('Passwords do not match.'); - } else { - setError('Something went wrong. Please try again.'); - } - }, - }); - - const isPending = resetMutation.isPending; - const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0; - - if (!isOpen) return null; - - return ( - <> - {/* Backdrop */} -