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)',
+ }
+ }
>