Files
familysync/apps/pwa/src/components/SettingsSheet.tsx
T

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>
</>
);
}