From 2c2c71e7cc36fef5c1aa0493042293cfc0b50f62 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 15:03:57 -0400 Subject: [PATCH] feat(10-04): add CredentialSheet and SetupBanner components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CredentialSheet: admin-rotate/admin-add/self-service modes, role=dialog, aria-modal - Password field type=password autoComplete=new-password, never pre-filled (T-10-16) - Fastmail link target=_blank rel=noopener noreferrer (UI-SPEC Surface 3) - Loader2 spinner + CalDAV failure copy on mutation error - Success invalidates ['admin','members'] + ['me'] → SetupBanner unmounts - Escape closes, focus returns to trigger (a11y) - SetupBanner: renders on needsProviderSetup=true only, role=status aria-live=polite - KeyRound icon, 'Set up your calendar' heading, 'Set up now' CTA (no X/dismiss) - Success-only dismissal: ['me'] invalidation is the ONLY code path to hide the banner - All styling via var(--token); 44px touch targets throughout --- apps/pwa/src/components/CredentialSheet.tsx | 394 ++++++++++++++++++++ apps/pwa/src/components/SetupBanner.tsx | 131 +++++++ 2 files changed, 525 insertions(+) create mode 100644 apps/pwa/src/components/CredentialSheet.tsx create mode 100644 apps/pwa/src/components/SetupBanner.tsx diff --git a/apps/pwa/src/components/CredentialSheet.tsx b/apps/pwa/src/components/CredentialSheet.tsx new file mode 100644 index 0000000..623535a --- /dev/null +++ b/apps/pwa/src/components/CredentialSheet.tsx @@ -0,0 +1,394 @@ +/** + * 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; + + return ( + <> + {/* Backdrop */} +