/** * SettingsSheet — master notifications toggle (D-09). * * A bottom sheet opened by tapping the user avatar in AppNav. * Contains a single master on/off toggle for all FamilySync push notifications. * * UI-SPEC §Surface 2: * - Bottom sheet, role="dialog", aria-modal, zIndex 301 (backdrop 300) * - Heading "Settings" + X close button * - Section label "Notifications" (uppercase, muted) * - Bell icon + toggle row * - Permission-denied hint when Notification.permission === 'denied' * * Toggle behavior: * on → off: DELETE subscription + localStorage.notificationsEnabled='0' * off → on (permission granted): silently subscribe (no OS dialog) * off → on (permission default): triggers tap-gated subscribe() (OS dialog) * off → on (permission denied): no-op, shows permission-denied hint inline * * Accessibility: role="switch", aria-checked, 44px touch targets, Escape closes. * Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML. */ import { useEffect, useRef, useState } from 'react'; import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { InstructionSheet } from './InstructionSheet.js'; import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc } from '../api/client.js'; // CR-04: fetch VAPID key (from sessionStorage cache if available) for the // tap-gated subscribe() path. Same logic as PushPermissionPrompt. async function fetchVapidKeyForSettings(): Promise { try { const cached = sessionStorage.getItem('vapidPublicKey'); if (cached) return cached; const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' }); if (!res.ok) return null; const data = (await res.json()) as { publicKey: string }; if (data.publicKey) { sessionStorage.setItem('vapidPublicKey', data.publicKey); } return data.publicKey ?? null; } catch { return null; } } interface SettingsSheetProps { isOpen: boolean; onClose: () => void; } export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription(); const [isTogglingOn, setIsTogglingOn] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false); // Phase 19: read meData (same query key as App.tsx — TanStack deduplicates the request) const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 0, }); const authModeQuery = useQuery({ queryKey: ['authMode'], queryFn: fetchAuthMode, retry: false, staleTime: 60_000, }); const hasLocalCredential = meQuery.data?.user.hasLocalCredential ?? false; const oidcEnabled = authModeQuery.data?.oidcEnabled ?? false; // Change-password sheet state (Surface 12) const [changePasswordOpen, setChangePasswordOpen] = useState(false); // Link-OIDC confirmation sheet state (Surface 13) const [linkOidcOpen, setLinkOidcOpen] = useState(false); // CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call // subscribe(registration, vapidKey) without any network await before pushManager.subscribe(). const [vapidKey, setVapidKey] = useState(null); // NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle // tap handler has ZERO awaits between the user gesture and pushManager.subscribe(). const [swRegistration, setSwRegistration] = useState(null); const closeButtonRef = useRef(null); // Compute initial toggle on/off state per UI-SPEC toggle initial state rule: // on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed const isOn = permission === 'granted' && isSubscribed; // Escape key listener (CreateListSheet pattern) 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 close button on open (a11y) useEffect(() => { if (isOpen && closeButtonRef.current) { closeButtonRef.current.focus(); } }, [isOpen]); // CR-04: Pre-fetch the VAPID key while the sheet is open so the OS-dialog path // (permission === 'default') has the key ready before the user taps. useEffect(() => { if (!isOpen) return; if (permission === 'denied') return; void fetchVapidKeyForSettings().then((key) => { if (key) setVapidKey(key); }); }, [isOpen, permission]); // NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle // tap handler never needs to await navigator.serviceWorker.ready. Any await // between the tap gesture and pushManager.subscribe() breaks iOS. useEffect(() => { if (!isOpen) return; if (permission === 'denied') return; if (!navigator.serviceWorker) return; void navigator.serviceWorker.ready.then((reg) => { setSwRegistration(reg); }); }, [isOpen, permission]); if (!isOpen) return null; const handleToggle = async () => { if (permission === 'denied') return; // no-op — show hint below if (isOn) { // on → off: unsubscribe await setEnabled(false); } else if (permission === 'granted') { // off → on, permission already granted: silent subscribe setIsTogglingOn(true); try { await setEnabled(true); } finally { setIsTogglingOn(false); } } else { // off → on, permission 'default': needs tap-gated subscribe with OS dialog // The toggle click IS the tap gesture — call subscribe() directly here. // NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved into state // (useEffects above). There is ZERO await between the tap and // registration.pushManager.subscribe() — iOS gesture gate satisfied. if (!vapidKey || !swRegistration) return; // not ready — useEffects still resolving const resolvedVapidKey = vapidKey; const resolvedRegistration = swRegistration; setIsTogglingOn(true); try { await subscribe(resolvedRegistration, resolvedVapidKey); } catch { // Permission denied by OS or error — permission state will update reactively } finally { setIsTogglingOn(false); } } }; const isDisabled = permission === 'denied'; return ( <> {/* Backdrop */}