1027 lines
33 KiB
TypeScript
1027 lines
33 KiB
TypeScript
/**
|
|
* 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<string | null> {
|
|
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<string | null>(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<ServiceWorkerRegistration | null>(null);
|
|
const closeButtonRef = useRef<HTMLButtonElement>(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 */}
|
|
<div
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
|
zIndex: 300,
|
|
}}
|
|
/>
|
|
|
|
{/* Sheet */}
|
|
<div
|
|
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',
|
|
}}
|
|
>
|
|
{/* Heading row */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 'var(--space-6, 24px)',
|
|
}}
|
|
>
|
|
<h2
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 'var(--text-heading-size, 18px)',
|
|
fontWeight: 600,
|
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
|
color: 'var(--color-text-primary, #111318)',
|
|
}}
|
|
>
|
|
Settings
|
|
</h2>
|
|
<button
|
|
ref={closeButtonRef}
|
|
onClick={onClose}
|
|
aria-label="Close settings"
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
minWidth: '44px',
|
|
minHeight: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
<X size={20} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Section label */}
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-muted, #9CA3AF)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.06em',
|
|
marginBottom: 'var(--space-2, 8px)',
|
|
}}
|
|
>
|
|
Notifications
|
|
</div>
|
|
|
|
{/* Toggle row */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3, 12px)',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
}}
|
|
>
|
|
<Bell
|
|
size={20}
|
|
aria-hidden="true"
|
|
style={{
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
|
|
{/* Label column */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontWeight: 400,
|
|
color: 'var(--color-text-primary, #111318)',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
FamilySync Notifications
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
lineHeight: 1.4,
|
|
marginTop: '2px',
|
|
}}
|
|
>
|
|
Reminders, event changes, list updates
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toggle switch or spinner */}
|
|
{isTogglingOn ? (
|
|
<Loader2
|
|
size={20}
|
|
aria-hidden="true"
|
|
style={{
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
animation: 'spin 1s linear infinite',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
) : (
|
|
<button
|
|
role="switch"
|
|
aria-checked={isOn}
|
|
aria-label={isOn ? 'FamilySync Notifications, on' : 'FamilySync Notifications, off'}
|
|
onClick={() => {
|
|
void handleToggle();
|
|
}}
|
|
disabled={isDisabled}
|
|
style={{
|
|
// 44px touch target
|
|
minWidth: '44px',
|
|
minHeight: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: isDisabled ? 'default' : 'pointer',
|
|
padding: 0,
|
|
flexShrink: 0,
|
|
opacity: isDisabled ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{/* Toggle track */}
|
|
<div
|
|
style={{
|
|
width: '44px',
|
|
height: '24px',
|
|
borderRadius: '12px',
|
|
background: isOn
|
|
? 'var(--color-member-0, #4A90D9)'
|
|
: 'var(--color-border, #E2E4E9)',
|
|
position: 'relative',
|
|
transition: 'background 0.15s ease',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{/* Toggle thumb */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '2px',
|
|
left: isOn ? '22px' : '2px',
|
|
width: '20px',
|
|
height: '20px',
|
|
borderRadius: '50%',
|
|
background: '#ffffff',
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
|
transition: 'left 0.15s ease',
|
|
}}
|
|
/>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Surface 12 — Change password row (hasLocalCredential gate) */}
|
|
{hasLocalCredential && (
|
|
<>
|
|
<div
|
|
style={{
|
|
height: '1px',
|
|
background: 'var(--color-border-subtle, var(--color-border))',
|
|
margin: 'var(--space-4, 16px) 0',
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-muted, #9CA3AF)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.06em',
|
|
marginBottom: 'var(--space-2, 8px)',
|
|
}}
|
|
>
|
|
Account
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setChangePasswordOpen(true)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
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-text-primary, #111318)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
textAlign: 'left',
|
|
}}
|
|
>
|
|
Change password
|
|
</button>
|
|
|
|
{/* Surface 13 — Link OIDC identity row (hasLocalCredential + oidcEnabled gate) */}
|
|
{oidcEnabled && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setLinkOidcOpen(true)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
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-text-primary, #111318)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
textAlign: 'left',
|
|
}}
|
|
>
|
|
Link OIDC identity
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Permission-denied hint — only when OS permission === 'denied' */}
|
|
{permission === 'denied' && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 'var(--space-2, 8px)',
|
|
marginTop: 'var(--space-2, 8px)',
|
|
padding: 'var(--space-3, 12px)',
|
|
background: 'var(--color-surface-dim, #F7F7F8)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
<AlertCircle
|
|
size={16}
|
|
aria-hidden="true"
|
|
style={{
|
|
color: 'var(--color-destructive, #DC2626)',
|
|
flexShrink: 0,
|
|
marginTop: '1px',
|
|
}}
|
|
/>
|
|
<div>
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
Notifications are blocked in your browser settings.{' '}
|
|
</span>
|
|
<button
|
|
onClick={() => setInstructionsOpen(true)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-focus-ring, #4A90D9)',
|
|
textDecoration: 'underline',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
How to enable
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
|
|
|
|
{/* Surface 12 — Change-password sheet (hasLocalCredential gate) */}
|
|
{changePasswordOpen && (
|
|
<ChangePasswordSheet
|
|
isOpen={changePasswordOpen}
|
|
onClose={() => setChangePasswordOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
|
|
{linkOidcOpen && (
|
|
<LinkOidcSheet
|
|
isOpen={linkOidcOpen}
|
|
onClose={() => setLinkOidcOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── ChangePasswordSheet (Surface 12) ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Surface 12 — Self-service password change sheet.
|
|
* Opens from the "Change password" row in SettingsSheet.
|
|
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus heading on open).
|
|
* Fields: Current password / New password / Confirm — correct autoComplete values.
|
|
* Security: T-19-18 — password fields are controlled state only; never written to storage.
|
|
*/
|
|
|
|
interface ChangePasswordSheetProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') handleClose();
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
if (isOpen && headingRef.current) {
|
|
headingRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
function handleClose() {
|
|
setCurrentPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
setError(null);
|
|
onClose();
|
|
}
|
|
|
|
const changeMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
|
await fetchChangePassword({ currentPassword, newPassword });
|
|
},
|
|
onSuccess: () => {
|
|
handleClose();
|
|
},
|
|
onError: (err) => {
|
|
const msg = err instanceof Error ? err.message : 'server';
|
|
if (msg === 'mismatch') {
|
|
setError('Passwords do not match.');
|
|
} else if (msg === 'wrong-current') {
|
|
setError('Current password is incorrect.');
|
|
} else {
|
|
setError('Something went wrong. Please try again.');
|
|
}
|
|
},
|
|
});
|
|
|
|
const isPending = changeMutation.isPending;
|
|
const submitDisabled =
|
|
isPending ||
|
|
currentPassword.length === 0 ||
|
|
newPassword.length === 0 ||
|
|
confirmPassword.length === 0;
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
onClick={handleClose}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
|
zIndex: 302,
|
|
}}
|
|
/>
|
|
|
|
{/* Sheet */}
|
|
<div
|
|
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',
|
|
}}
|
|
>
|
|
<h2
|
|
ref={headingRef}
|
|
tabIndex={-1}
|
|
style={{
|
|
margin: '0 0 var(--space-6, 24px) 0',
|
|
fontSize: 'var(--text-heading-size, 18px)',
|
|
fontWeight: 600,
|
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
|
color: 'var(--color-text-primary, #111318)',
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
Change password
|
|
</h2>
|
|
|
|
{/* Current password */}
|
|
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
|
<label
|
|
htmlFor="change-current-password"
|
|
style={{
|
|
display: 'block',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary, #111318)',
|
|
marginBottom: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
Current password
|
|
</label>
|
|
<input
|
|
id="change-current-password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
style={{
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
|
border: `1px solid ${error === 'Current password is incorrect.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
color: 'var(--color-text-primary, #111318)',
|
|
background: 'var(--color-surface, #ffffff)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
outline: 'none',
|
|
minHeight: '44px',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* New password */}
|
|
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
|
<label
|
|
htmlFor="change-new-password"
|
|
style={{
|
|
display: 'block',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary, #111318)',
|
|
marginBottom: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
New password
|
|
</label>
|
|
<input
|
|
id="change-new-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
style={{
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
|
border: `1px solid ${error === 'Passwords do not match.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
color: 'var(--color-text-primary, #111318)',
|
|
background: 'var(--color-surface, #ffffff)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
outline: 'none',
|
|
minHeight: '44px',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Confirm new password */}
|
|
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
|
<label
|
|
htmlFor="change-confirm-password"
|
|
style={{
|
|
display: 'block',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary, #111318)',
|
|
marginBottom: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
Confirm
|
|
</label>
|
|
<input
|
|
id="change-confirm-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
aria-describedby={error ? 'change-password-error' : undefined}
|
|
style={{
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
|
border: `1px solid ${error === 'Passwords do not match.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
color: 'var(--color-text-primary, #111318)',
|
|
background: 'var(--color-surface, #ffffff)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
outline: 'none',
|
|
minHeight: '44px',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div
|
|
id="change-password-error"
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 400,
|
|
color: 'var(--color-destructive, #dc2626)',
|
|
marginBottom: 'var(--space-4, 16px)',
|
|
}}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action row */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: 'var(--space-3, 12px)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
disabled={isPending}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: isPending ? 'default' : 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-secondary, #6b7280)',
|
|
minHeight: '44px',
|
|
minWidth: '44px',
|
|
padding: '0 var(--space-4, 16px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={submitDisabled}
|
|
onClick={() => {
|
|
setError(null);
|
|
changeMutation.mutate();
|
|
}}
|
|
style={{
|
|
background: submitDisabled
|
|
? 'var(--color-border, #e2e4e9)'
|
|
: 'var(--color-member-0, #4a90d9)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: submitDisabled ? 'default' : 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
minHeight: '44px',
|
|
minWidth: '44px',
|
|
padding: '0 var(--space-4, 16px)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
transition: 'background 0.15s ease',
|
|
}}
|
|
>
|
|
Change password
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── LinkOidcSheet (Surface 13) ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Surface 13 — Link OIDC identity confirmation sheet.
|
|
* NOT a form — the actual linking happens via OIDC redirect.
|
|
* Two-step confirmation: open sheet (step 1) + tap "Continue with OIDC" (step 2).
|
|
*
|
|
* Copywriting rules (UI-SPEC §Copywriting Contract):
|
|
* - Never use "Authelia" (D-06) — use "your OIDC provider"
|
|
* - Never say "delete" or "remove" when describing consequence — use "will be removed" (passive)
|
|
* - Body copy: non-alarming, frames linking as an upgrade
|
|
*
|
|
* Security: T-19-21 — no provider-specific branding that leaks infrastructure details.
|
|
*/
|
|
|
|
interface LinkOidcSheetProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
|
const [error, setError] = useState<string | null>(null);
|
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && headingRef.current) {
|
|
headingRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const linkMutation = useMutation({
|
|
mutationFn: fetchLinkOidc,
|
|
onSuccess: (data) => {
|
|
// authorizationUrl is null when OIDC is not configured in env (server could not
|
|
// build the URL). Do NOT navigate to null — surface an error and keep the sheet open.
|
|
if (!data.authorizationUrl) {
|
|
setError('Something went wrong. Please try again.');
|
|
return;
|
|
}
|
|
// Close the sheet and initiate the OIDC link flow via top-level navigation.
|
|
onClose();
|
|
window.location.href = data.authorizationUrl;
|
|
},
|
|
onError: () => {
|
|
setError('Something went wrong. Please try again.');
|
|
},
|
|
});
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
|
zIndex: 302,
|
|
}}
|
|
/>
|
|
|
|
{/* Sheet */}
|
|
<div
|
|
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',
|
|
}}
|
|
>
|
|
<h2
|
|
ref={headingRef}
|
|
tabIndex={-1}
|
|
style={{
|
|
margin: '0 0 var(--space-4, 16px) 0',
|
|
fontSize: 'var(--text-heading-size, 18px)',
|
|
fontWeight: 600,
|
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
|
color: 'var(--color-text-primary, #111318)',
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
Link OIDC identity
|
|
</h2>
|
|
|
|
{/* Body — informational, not alarming (UI-SPEC §Copywriting Contract) */}
|
|
<p
|
|
style={{
|
|
margin: '0 0 var(--space-3, 12px) 0',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontWeight: 400,
|
|
lineHeight: 'var(--text-body-line-height, 1.5)',
|
|
color: 'var(--color-text-secondary, #6b7280)',
|
|
}}
|
|
>
|
|
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."}
|
|
</p>
|
|
|
|
{/* Secondary note */}
|
|
<p
|
|
style={{
|
|
margin: '0 0 var(--space-6, 24px) 0',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 400,
|
|
lineHeight: 'var(--text-label-line-height, 1.4)',
|
|
color: 'var(--color-text-muted, #9ca3af)',
|
|
}}
|
|
>
|
|
{"This can't be undone from the app. Contact your admin if you need to revert."}
|
|
</p>
|
|
|
|
{/* Error (post-fetch) */}
|
|
{error && (
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-destructive, #dc2626)',
|
|
marginBottom: 'var(--space-4, 16px)',
|
|
}}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action row */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: 'var(--space-3, 12px)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={linkMutation.isPending}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: linkMutation.isPending ? 'default' : 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-secondary, #6b7280)',
|
|
minHeight: '44px',
|
|
minWidth: '44px',
|
|
padding: '0 var(--space-4, 16px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={linkMutation.isPending}
|
|
onClick={() => {
|
|
setError(null);
|
|
linkMutation.mutate();
|
|
}}
|
|
style={{
|
|
background: linkMutation.isPending
|
|
? 'var(--color-border, #e2e4e9)'
|
|
: 'var(--color-member-0, #4a90d9)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: linkMutation.isPending ? 'default' : 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
minHeight: '44px',
|
|
minWidth: '44px',
|
|
padding: '0 var(--space-4, 16px)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
transition: 'background 0.15s ease',
|
|
}}
|
|
>
|
|
Continue with OIDC
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|