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:
@@ -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,20 +200,37 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
style={{
|
style={
|
||||||
position: 'fixed',
|
phone
|
||||||
bottom: 0,
|
? {
|
||||||
left: 0,
|
position: 'fixed',
|
||||||
right: 0,
|
bottom: 0,
|
||||||
background: 'var(--color-surface-raised, #ffffff)',
|
left: 0,
|
||||||
borderRadius: '12px 12px 0 0',
|
right: 0,
|
||||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
padding: 'var(--space-6, 24px)',
|
borderRadius: '12px 12px 0 0',
|
||||||
zIndex: 301,
|
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||||
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
padding: 'var(--space-6, 24px)',
|
||||||
maxWidth: '480px',
|
zIndex: 301,
|
||||||
margin: '0 auto',
|
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 */}
|
{/* 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,20 +668,37 @@ 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={
|
||||||
position: 'fixed',
|
phone
|
||||||
bottom: 0,
|
? {
|
||||||
left: 0,
|
position: 'fixed',
|
||||||
right: 0,
|
bottom: 0,
|
||||||
background: 'var(--color-surface-raised, #ffffff)',
|
left: 0,
|
||||||
borderRadius: '12px 12px 0 0',
|
right: 0,
|
||||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
padding: 'var(--space-6, 24px)',
|
borderRadius: '12px 12px 0 0',
|
||||||
zIndex: 303,
|
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||||
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
padding: 'var(--space-6, 24px)',
|
||||||
maxWidth: '480px',
|
zIndex: 303,
|
||||||
margin: '0 auto',
|
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)',
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<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,20 +980,37 @@ 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={
|
||||||
position: 'fixed',
|
phone
|
||||||
bottom: 0,
|
? {
|
||||||
left: 0,
|
position: 'fixed',
|
||||||
right: 0,
|
bottom: 0,
|
||||||
background: 'var(--color-surface-raised, #ffffff)',
|
left: 0,
|
||||||
borderRadius: '12px 12px 0 0',
|
right: 0,
|
||||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
padding: 'var(--space-6, 24px)',
|
borderRadius: '12px 12px 0 0',
|
||||||
zIndex: 303,
|
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||||
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
padding: 'var(--space-6, 24px)',
|
||||||
maxWidth: '480px',
|
zIndex: 303,
|
||||||
margin: '0 auto',
|
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)',
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
ref={headingRef}
|
ref={headingRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user