feat(17-05): add Sign out control with handleSignOut to SettingsSheet (D-07)

- Add LogOut icon (lucide-react) and fetchLocalLogout (api/client) imports
- Add useNavigate (react-router) for post-logout redirect
- Implement handleSignOut: fire-and-best-effort try/catch, navigate to /login in both branches
- Add Sign out button row at bottom of sheet (44px tap target, var(--color-destructive), divider separator)
- Always navigates to /login even on API failure (server cookie cleared or expired)
This commit is contained in:
Lucas Berger
2026-06-18 12:52:48 -04:00
parent 9e080fb22a
commit 132a5e4eae
+116 -11
View File
@@ -22,11 +22,12 @@
*/ */
import { useEffect, useRef, useState } from 'react'; 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 { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { InstructionSheet } from './InstructionSheet.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 // CR-04: fetch VAPID key (from sessionStorage cache if available) for the
// tap-gated subscribe() path. Same logic as PushPermissionPrompt. // tap-gated subscribe() path. Same logic as PushPermissionPrompt.
@@ -53,6 +54,7 @@ interface SettingsSheetProps {
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription(); const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
const navigate = useNavigate();
const [isTogglingOn, setIsTogglingOn] = useState(false); const [isTogglingOn, setIsTogglingOn] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false);
@@ -130,6 +132,19 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
if (!isOpen) return null; 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 () => { const handleToggle = async () => {
if (permission === 'denied') return; // no-op — show hint below if (permission === 'denied') return; // no-op — show hint below
@@ -185,7 +200,9 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Settings" aria-label="Settings"
style={{ style={
phone
? {
position: 'fixed', position: 'fixed',
bottom: 0, bottom: 0,
left: 0, left: 0,
@@ -196,9 +213,24 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
padding: 'var(--space-6, 24px)', padding: 'var(--space-6, 24px)',
zIndex: 301, zIndex: 301,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)', fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}
: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '480px', maxWidth: '480px',
margin: '0 auto', 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 */} {/* Heading row */}
<div <div
@@ -486,6 +518,41 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
</div> </div>
</div> </div>
)} )}
{/* D-07: Sign out — always visible at the bottom of the sheet */}
<div
style={{
height: '1px',
background: 'var(--color-border-subtle, var(--color-border))',
margin: 'var(--space-4, 16px) 0',
}}
/>
<button
type="button"
onClick={() => {
void handleSignOut();
}}
aria-label="Sign out"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
width: '100%',
minHeight: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2, 8px) 0',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-destructive, #dc2626)',
fontFamily: 'var(--font-family-base)',
textAlign: 'left',
}}
>
<LogOut size={16} aria-hidden="true" />
Sign out
</button>
</div> </div>
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />} {instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
@@ -580,6 +647,8 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
if (!isOpen) return null; if (!isOpen) return null;
const phone = window.matchMedia('(max-width: 767px)').matches;
return ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
@@ -599,7 +668,9 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Change password" aria-label="Change password"
style={{ style={
phone
? {
position: 'fixed', position: 'fixed',
bottom: 0, bottom: 0,
left: 0, left: 0,
@@ -610,9 +681,24 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
padding: 'var(--space-6, 24px)', padding: 'var(--space-6, 24px)',
zIndex: 303, zIndex: 303,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)', fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}
: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '480px', maxWidth: '480px',
margin: '0 auto', 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)',
}
}
> >
<h2 <h2
ref={headingRef} ref={headingRef}
@@ -873,6 +959,8 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
if (!isOpen) return null; if (!isOpen) return null;
const phone = window.matchMedia('(max-width: 767px)').matches;
return ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
@@ -892,7 +980,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Link OIDC identity" aria-label="Link OIDC identity"
style={{ style={
phone
? {
position: 'fixed', position: 'fixed',
bottom: 0, bottom: 0,
left: 0, left: 0,
@@ -903,9 +993,24 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
padding: 'var(--space-6, 24px)', padding: 'var(--space-6, 24px)',
zIndex: 303, zIndex: 303,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)', fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}
: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
maxWidth: '480px', maxWidth: '480px',
margin: '0 auto', 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)',
}
}
> >
<h2 <h2
ref={headingRef} ref={headingRef}