/** * CredentialSheet — credential bottom sheet for admin rotation and member self-service (D-07). * * Shared by two paths: * - admin-rotate: admin sets a credential for a member who already has one ("Rotate Credential") * - admin-add: admin sets a credential for a member who has none ("Add Credential") * - self-service: member sets their own credential ("Add your calendar credential") * * UI-SPEC §Surface 3: * - Bottom sheet: role="dialog", aria-modal, zIndex 301 (backdrop 300) * - borderRadius 12px 12px 0 0 / padding var(--space-6) / maxWidth 480px centered desktop * - Heading variant per mode, member-name subtitle, type="password" / autocomplete="new-password" (T-10-16) * - Helper text + Fastmail app-password link (new tab, rel="noopener noreferrer") (T-10-15) * - "Validating against CalDAV…" Loader2 spinner inline during mutation * - CalDAV 400 failure copy, Save Credential / Cancel actions * - Success: invalidates ['admin','members'] + ['me'] → needsProviderSetup refresh → SetupBanner unmounts * - Escape closes; focus returns to trigger on close * - 44px touch targets throughout * * Security: * T-10-15: password never pre-filled, never logged, never stored beyond in-flight request * T-10-16: autoComplete="new-password" prevents autofill of stored credential */ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; import { saveCredential, saveMyCredential, type SaveCredentialPayload, type SaveMyCredentialPayload, } from '../api/client.js'; export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service'; interface CredentialSheetProps { isOpen: boolean; onClose: () => void; /** Mode determines heading copy and which API endpoint is called */ mode: CredentialSheetMode; /** The member being targeted (admin modes). For self-service, the current user's name. */ memberName: string | null; /** The member's user id (admin modes only — ignored for self-service) */ memberId?: number; /** Ref to the trigger element — focus returns here on close (a11y) */ triggerRef?: React.RefObject; } // ── Copywriting contract (UI-SPEC §Copywriting Contract) ─────────────────── function headingFor(mode: CredentialSheetMode): string { if (mode === 'admin-rotate') return 'Rotate Credential'; if (mode === 'admin-add') return 'Add Credential'; return 'Add your calendar credential'; } const HELPER_TEXT = 'Enter the Fastmail app password scoped to Calendars/CalDAV.'; const HELPER_LINK_HREF = 'https://app.fastmail.com/settings/security/devicetokens'; const HELPER_LINK_TEXT = 'Get an app password'; const HELPER_LINK_SUFFIX = " — choose the 'Calendars & Contacts (CalDAV)' scope."; const VALIDATING_TEXT = 'Validating against CalDAV…'; const FAILURE_TEXT = "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again."; const SAVE_LABEL = 'Save Credential'; const CANCEL_LABEL = 'Cancel'; // ── Component ────────────────────────────────────────────────────────────── export function CredentialSheet({ isOpen, onClose, mode, memberName, memberId, triggerRef, }: CredentialSheetProps) { const queryClient = useQueryClient(); const [password, setPassword] = useState(''); const [email, setEmail] = useState(''); const [validationError, setValidationError] = useState(null); // Focus the heading/first focusable element on open (a11y) const headingRef = useRef(null); // Escape key closes the sheet (SettingsSheet pattern) useEffect(() => { if (!isOpen) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { handleClose(); } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps // Focus heading on open (a11y) useEffect(() => { if (isOpen && headingRef.current) { headingRef.current.focus(); } }, [isOpen]); function handleClose() { setPassword(''); setEmail(''); setValidationError(null); onClose(); // Return focus to trigger element (a11y) if (triggerRef?.current) { triggerRef.current.focus(); } } const credentialMutation = useMutation({ mutationFn: async () => { if (mode === 'self-service') { const payload: SaveMyCredentialPayload = { providerType: 'caldav', fastmailEmail: email, appPassword: password, }; await saveMyCredential(payload); } else { if (!memberId) throw new Error('memberId required for admin modes'); const payload: SaveCredentialPayload = { userId: memberId, providerType: 'caldav', fastmailEmail: email, appPassword: password, }; await saveCredential(payload); } }, onSuccess: () => { // Invalidate both caches: admin member list + /api/me (needsProviderSetup refresh) void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); void queryClient.invalidateQueries({ queryKey: ['me'] }); handleClose(); }, onError: () => { setValidationError(FAILURE_TEXT); }, }); const handleSave = () => { setValidationError(null); credentialMutation.mutate(); }; if (!isOpen) return null; const heading = headingFor(mode); const isPending = credentialMutation.isPending; const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0; const phone = window.matchMedia('(max-width: 767px)').matches; return ( <> {/* Backdrop */}