diff --git a/apps/pwa/src/components/SettingsSheet.tsx b/apps/pwa/src/components/SettingsSheet.tsx index a17acc5..263f5d8 100644 --- a/apps/pwa/src/components/SettingsSheet.tsx +++ b/apps/pwa/src/components/SettingsSheet.tsx @@ -22,11 +22,12 @@ */ import { useEffect, useRef, useState } from 'react'; -import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'; +import { X, Bell, AlertCircle, Loader2, LogOut } from 'lucide-react'; import { useQuery, useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { InstructionSheet } from './InstructionSheet.js'; -import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc } from '../api/client.js'; +import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js'; // CR-04: fetch VAPID key (from sessionStorage cache if available) for the // tap-gated subscribe() path. Same logic as PushPermissionPrompt. @@ -53,6 +54,7 @@ interface SettingsSheetProps { export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription(); + const navigate = useNavigate(); const [isTogglingOn, setIsTogglingOn] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false); @@ -130,6 +132,19 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { if (!isOpen) return null; + const phone = window.matchMedia('(max-width: 767px)').matches; + + // D-07: fire-and-best-effort logout — navigate to /login regardless of API success/failure + const handleSignOut = async () => { + try { + await fetchLocalLogout(); + } catch { + // API failure does not block navigation — server cookie was cleared or already expired + } + onClose(); + navigate('/login'); + }; + const handleToggle = async () => { if (permission === 'denied') return; // no-op — show hint below @@ -185,20 +200,37 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { role="dialog" aria-modal="true" aria-label="Settings" - style={{ - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - background: 'var(--color-surface-raised, #ffffff)', - 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, system-ui, sans-serif)', - maxWidth: '480px', - margin: '0 auto', - }} + style={ + phone + ? { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + background: 'var(--color-surface-raised, #ffffff)', + 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, system-ui, sans-serif)', + } + : { + 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-raised, #ffffff)', + borderRadius: '12px', + boxShadow: '0 8px 32px rgba(0,0,0,0.18)', + padding: 'var(--space-6, 24px)', + zIndex: 301, + fontFamily: 'var(--font-family-base, system-ui, sans-serif)', + } + } > {/* Heading row */}
)} + + {/* D-07: Sign out — always visible at the bottom of the sheet */} +
+
{instructionsOpen && setInstructionsOpen(false)} />} @@ -580,6 +647,8 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) { if (!isOpen) return null; + const phone = window.matchMedia('(max-width: 767px)').matches; + return ( <> {/* Backdrop */} @@ -599,20 +668,37 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) { role="dialog" aria-modal="true" aria-label="Change password" - style={{ - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - background: 'var(--color-surface-raised, #ffffff)', - borderRadius: '12px 12px 0 0', - boxShadow: '0 -4px 24px rgba(0,0,0,0.15)', - padding: 'var(--space-6, 24px)', - zIndex: 303, - fontFamily: 'var(--font-family-base, system-ui, sans-serif)', - maxWidth: '480px', - margin: '0 auto', - }} + style={ + phone + ? { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + background: 'var(--color-surface-raised, #ffffff)', + borderRadius: '12px 12px 0 0', + boxShadow: '0 -4px 24px rgba(0,0,0,0.15)', + padding: 'var(--space-6, 24px)', + zIndex: 303, + fontFamily: 'var(--font-family-base, system-ui, sans-serif)', + } + : { + 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-raised, #ffffff)', + borderRadius: '12px', + boxShadow: '0 8px 32px rgba(0,0,0,0.18)', + padding: 'var(--space-6, 24px)', + zIndex: 303, + fontFamily: 'var(--font-family-base, system-ui, sans-serif)', + } + } >

{/* Backdrop */} @@ -892,20 +980,37 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) { role="dialog" aria-modal="true" aria-label="Link OIDC identity" - style={{ - position: 'fixed', - bottom: 0, - left: 0, - right: 0, - background: 'var(--color-surface-raised, #ffffff)', - borderRadius: '12px 12px 0 0', - boxShadow: '0 -4px 24px rgba(0,0,0,0.15)', - padding: 'var(--space-6, 24px)', - zIndex: 303, - fontFamily: 'var(--font-family-base, system-ui, sans-serif)', - maxWidth: '480px', - margin: '0 auto', - }} + style={ + phone + ? { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + background: 'var(--color-surface-raised, #ffffff)', + borderRadius: '12px 12px 0 0', + boxShadow: '0 -4px 24px rgba(0,0,0,0.15)', + padding: 'var(--space-6, 24px)', + zIndex: 303, + fontFamily: 'var(--font-family-base, system-ui, sans-serif)', + } + : { + 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-raised, #ffffff)', + borderRadius: '12px', + boxShadow: '0 8px 32px rgba(0,0,0,0.18)', + padding: 'var(--space-6, 24px)', + zIndex: 303, + fontFamily: 'var(--font-family-base, system-ui, sans-serif)', + } + } >