From b125a69b586abfdecf28c1b7828367fa43298efa Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:31:46 -0400 Subject: [PATCH] feat(20-03): add MemberEditorSheet with edit/create modes and per-section saves - Edit mode: Profile (display name + admin toggle), Set new password, App password sections - Create mode: single form with display name, username, initial/confirm password - Per-section saves keep sheet open; create success closes sheet (D-05, D-07) - Admin toggle role=switch, aria-checked; last-admin 409 shows inline error + reverts (D-03) - Section 2 gated on member.hasLocalCredential; passwords never prefilled (T-20-07) - App password save routes through saveCredential -> CalDAV validation (T-20-08) - No Rotate/Add credential/Reset password copy (D-06) --- apps/pwa/src/components/MemberEditorSheet.tsx | 899 ++++++++++++++++++ 1 file changed, 899 insertions(+) create mode 100644 apps/pwa/src/components/MemberEditorSheet.tsx diff --git a/apps/pwa/src/components/MemberEditorSheet.tsx b/apps/pwa/src/components/MemberEditorSheet.tsx new file mode 100644 index 0000000..f770391 --- /dev/null +++ b/apps/pwa/src/components/MemberEditorSheet.tsx @@ -0,0 +1,899 @@ +/** + * MemberEditorSheet — unified member editor sheet (Phase 20, D-05, D-06, D-07). + * + * Single component with a `mode` prop: + * - 'edit' — three per-section saves: Profile, Set new password, App password + * - 'create' — single form: display name, username, initial password, confirm + * + * UI-SPEC §Surface B (20-UI-SPEC.md): + * - role="dialog", aria-modal, useFocusTrap, Escape closes, focus returns to trigger + * - Phone: fixed bottom bottom-sheet; Desktop: centered modal (480px) + * - zIndex 301 / backdrop 300 / overlay rgba(0,0,0,0.32) + * - Per-section saves keep the sheet open; create-mode save closes it + * - Admin toggle: role="switch", aria-checked (Accessibility Contract) + * + * Security: + * T-20-07: password/app-password fields are write-only: never prefilled, + * autoComplete="new-password", never logged (T-10-15/16 preserved) + * T-20-08: app-password save routes through saveCredential → server-side CalDAV + * validation; invalid password surfaces copy, nothing stored + * T-20-09: admin toggle is cosmetic; D-03 409 guard enforced server-side (Plan 20-01) + * + * Retired copy (D-06): per-row credential buttons and standalone password-reset button removed from this phase. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { + updateMemberProfile, + fetchAdminResetPassword, + fetchCreateMember, + saveCredential, + type AdminMember, +} from '../api/client.js'; +import { useIsPhone } from '../hooks/useIsPhone.js'; +import { useFocusTrap } from '../hooks/useFocusTrap.js'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export type MemberEditorSheetMode = 'edit' | 'create'; + +interface MemberEditorSheetProps { + isOpen: boolean; + onClose: () => void; + mode: MemberEditorSheetMode; + /** Present in edit mode; absent in create mode */ + member?: AdminMember; + /** Ref to the trigger element — focus returns here on close (a11y) */ + triggerRef?: React.RefObject; + /** Lift toast copy up to AdminPage which owns the toast state */ + onToast: (message: string) => void; +} + +// ── Copywriting (UI-SPEC §Copywriting Contract) ─────────────────────────── + +function headingFor(mode: MemberEditorSheetMode): string { + return mode === 'edit' ? 'Edit member' : 'Add member'; +} + +const CALDAV_FAILURE_TEXT = + "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again."; +const CALDAV_VALIDATING_TEXT = 'Validating against CalDAV…'; +const CALDAV_LINK_HREF = 'https://app.fastmail.com/settings/security/devicetokens'; +const CALDAV_LINK_TEXT = 'Get an app password'; + +// ── Shared field style helpers ───────────────────────────────────────────── + +const inputStyle = (hasError: boolean): React.CSSProperties => ({ + width: '100%', + boxSizing: 'border-box', + padding: 'var(--space-3, 12px) var(--space-4, 16px)', + border: `1px solid ${hasError ? 'var(--color-destructive, #DC2626)' : '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', +}); + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + color: 'var(--color-text-primary)', + marginBottom: 'var(--space-1, 4px)', +}; + +const sectionLabelStyle: React.CSSProperties = { + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + color: 'var(--color-text-muted)', + textTransform: 'uppercase', + letterSpacing: '0.06em', + marginBottom: 'var(--space-4, 16px)', +}; + +const sectionDividerStyle: React.CSSProperties = { + borderTop: '1px solid var(--color-border-subtle)', + margin: 'var(--space-6, 24px) 0', +}; + +const fieldContainerStyle: React.CSSProperties = { + marginBottom: 'var(--space-3, 12px)', +}; + +const inlineErrorStyle: React.CSSProperties = { + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 400, + color: 'var(--color-destructive, #DC2626)', + marginTop: 'var(--space-2, 8px)', +}; + +const helperTextStyle: React.CSSProperties = { + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 400, + color: 'var(--color-text-secondary)', + marginBottom: 'var(--space-3, 12px)', + lineHeight: 1.4, +}; + +function primaryButtonStyle(disabled: boolean): React.CSSProperties { + return { + background: disabled ? 'var(--color-border, #E2E4E9)' : 'var(--color-member-0, #e8915a)', + color: '#ffffff', + border: 'none', + cursor: disabled ? 'default' : 'pointer', + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + minHeight: '44px', + minWidth: '44px', + padding: '0 var(--space-4, 16px)', + borderRadius: 'var(--space-1, 4px)', + fontFamily: 'var(--font-family-base)', + transition: 'background 0.15s ease', + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2, 8px)', + }; +} + +const cancelButtonStyle: React.CSSProperties = { + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: 'var(--text-label-size, 13px)', + fontWeight: 600, + color: 'var(--color-text-secondary)', + minHeight: '44px', + minWidth: '44px', + padding: '0 var(--space-4, 16px)', + fontFamily: 'var(--font-family-base)', + borderRadius: 'var(--space-1, 4px)', +}; + +const actionsRowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'flex-end', + gap: 'var(--space-3, 12px)', + marginTop: 'var(--space-4, 16px)', +}; + +// ── Component ────────────────────────────────────────────────────────────── + +export function MemberEditorSheet({ + isOpen, + onClose, + mode, + member, + triggerRef, + onToast, +}: MemberEditorSheetProps) { + const queryClient = useQueryClient(); + const phone = useIsPhone(); + + // Focus management refs + const headingRef = useRef(null); + const dialogRef = useRef(null); + const handleDialogKeyDown = useFocusTrap(dialogRef); + + // ── Profile section state ──────────────────────────────────────────────── + const [displayName, setDisplayName] = useState(member?.displayName ?? ''); + const [isAdmin, setIsAdmin] = useState(member?.isAdmin ?? false); + const [profileError, setProfileError] = useState(null); + + // ── Set new password section state ────────────────────────────────────── + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(null); + + // ── App password section state ─────────────────────────────────────────── + // NOTE: GET /api/admin/members does NOT return fastmailEmail; the stored + // fastmail_email lives only on the member_credentials row, which is not + // surfaced to the admin list. So the email field starts BLANK on edit — + // the admin must re-enter it. This is intentional (see D-CONTEXT note). + const [fastmailEmail, setFastmailEmail] = useState(''); + const [appPassword, setAppPassword] = useState(''); + const [appPasswordError, setAppPasswordError] = useState(null); + + // ── Create mode state ─────────────────────────────────────────────────── + const [createDisplayName, setCreateDisplayName] = useState(''); + const [createUsername, setCreateUsername] = useState(''); + const [createPassword, setCreatePassword] = useState(''); + const [createConfirmPassword, setCreateConfirmPassword] = useState(''); + const [createError, setCreateError] = useState(null); + + // ── handleClose ────────────────────────────────────────────────────────── + + const handleClose = useCallback(() => { + // Reset all form state + setDisplayName(member?.displayName ?? ''); + setIsAdmin(member?.isAdmin ?? false); + setProfileError(null); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(null); + setFastmailEmail(''); + setAppPassword(''); + setAppPasswordError(null); + setCreateDisplayName(''); + setCreateUsername(''); + setCreatePassword(''); + setCreateConfirmPassword(''); + setCreateError(null); + onClose(); + // Return focus to trigger element (a11y) + if (triggerRef?.current) { + triggerRef.current.focus(); + } + }, [onClose, triggerRef, member?.displayName, member?.isAdmin]); + + // Sync profile state when member changes (different row opened) + useEffect(() => { + setDisplayName(member?.displayName ?? ''); + setIsAdmin(member?.isAdmin ?? false); + setProfileError(null); + }, [member?.id, member?.displayName, member?.isAdmin]); + + // Escape key closes the sheet + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isOpen, handleClose]); + + // Focus heading on open (a11y) + useEffect(() => { + if (isOpen && headingRef.current) { + headingRef.current.focus(); + } + }, [isOpen]); + + // ── Profile mutation (Section 1) ───────────────────────────────────────── + + const profileMutation = useMutation({ + mutationFn: async () => { + if (!member) throw new Error('no-member'); + await updateMemberProfile(member.id, { + displayName: displayName.trim(), + isAdmin, + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + onToast('Profile saved.'); + // Sheet stays open — per-section save (D-05) + }, + 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); + setProfileError('Cannot remove admin — at least one admin must remain.'); + } else { + setProfileError('Something went wrong. Please try again.'); + } + }, + }); + + // ── Set new password mutation (Section 2) ──────────────────────────────── + + const passwordMutation = useMutation({ + mutationFn: async () => { + if (!member) throw new Error('no-member'); + if (newPassword !== confirmPassword) throw new Error('mismatch'); + if (newPassword.length < 8) throw new Error('short'); + await fetchAdminResetPassword(member.id, newPassword); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(null); + onToast('Password updated.'); + // Sheet stays open — per-section save (D-05) + }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'server'; + if (msg === 'mismatch') { + setPasswordError('Passwords do not match.'); + } else if (msg === 'short') { + setPasswordError('Password must be at least 8 characters.'); + } else { + setPasswordError('Something went wrong. Please try again.'); + } + }, + }); + + // ── App password mutation (Section 3) ──────────────────────────────────── + + const appPasswordMutation = useMutation({ + mutationFn: async () => { + if (!member) throw new Error('no-member'); + await saveCredential({ + userId: member.id, + providerType: 'caldav', + fastmailEmail: fastmailEmail.trim(), + appPassword, + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + void queryClient.invalidateQueries({ queryKey: ['me'] }); + setFastmailEmail(''); + setAppPassword(''); + setAppPasswordError(null); + onToast('App password saved.'); + // Sheet stays open — per-section save (D-05) + }, + onError: () => { + setAppPasswordError(CALDAV_FAILURE_TEXT); + }, + }); + + // ── Create member mutation ──────────────────────────────────────────────── + + const createMutation = useMutation({ + mutationFn: async () => { + 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: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + onToast('Member added.'); + // Create mode closes on success (D-07) + handleClose(); + }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'server'; + if (msg === 'mismatch') { + setCreateError('Passwords do not match.'); + } else if (msg === 'short') { + setCreateError('Password must be 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.'); + } + }, + }); + + // ── Render guard ────────────────────────────────────────────────────────── + + if (!isOpen) return null; + + const heading = headingFor(mode); + + // ── Sheet style ─────────────────────────────────────────────────────────── + + const sheetStyle: React.CSSProperties = phone + ? { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + background: 'var(--color-surface)', + borderRadius: '12px 12px 0 0', + boxShadow: '0 -4px 24px rgba(0,0,0,0.15)', + padding: 'var(--space-6, 24px)', + zIndex: 301, + fontFamily: 'var(--font-family-base)', + } + : { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + maxWidth: '480px', + width: 'calc(100% - var(--space-8, 32px))', + maxHeight: 'calc(100dvh - var(--space-8, 32px))', + overflowY: 'auto', + background: 'var(--color-surface)', + borderRadius: '12px', + boxShadow: '0 8px 32px rgba(0,0,0,0.18)', + padding: 'var(--space-6, 24px)', + zIndex: 301, + fontFamily: 'var(--font-family-base)', + }; + + return ( + <> + {/* Backdrop — uses --color-overlay per UI-SPEC §Surface B (matches ResetPasswordSheet) */} +