/** * 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 ────────────────────────────────────────────────────────── // 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(() => { 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]); // 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'); // 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: (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) }, onError: (err) => { const msg = err instanceof Error ? err.message : 'server'; if (msg === 'last-admin') { // 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.'); } }, }); // ── 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, // 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)', } : { 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) */}