Files
familysync/apps/pwa/src/components/CredentialSheet.tsx
T
Lucas Berger b71238634f feat(17-05): add phone/desktop style branch to CredentialSheet (D-09)
- Add window.matchMedia('(max-width: 767px)') phone check
- Desktop branch: position fixed, top/left 50%, translate(-50%, -50%), maxWidth 480px, borderRadius 12px
- Phone branch: unchanged bottom-sheet (bottom 0, left 0, right 0, borderRadius 12px 12px 0 0)
- role=dialog, aria-modal, aria-label unchanged — only position CSS branched
- SettingsSheet, ChangePasswordSheet, LinkOidcSheet centering in prior commit (Task 1)
2026-06-18 12:52:59 -04:00

415 lines
14 KiB
TypeScript

/**
* CredentialSheet — credential bottom sheet for admin rotation and member self-service (D-07).
*
* Shared by two paths:
* - admin-rotate: admin sets a credential for a member who already has one ("Rotate Credential")
* - admin-add: admin sets a credential for a member who has none ("Add Credential")
* - self-service: member sets their own credential ("Add your calendar credential")
*
* UI-SPEC §Surface 3:
* - Bottom sheet: role="dialog", aria-modal, zIndex 301 (backdrop 300)
* - borderRadius 12px 12px 0 0 / padding var(--space-6) / maxWidth 480px centered desktop
* - Heading variant per mode, member-name subtitle, type="password" / autocomplete="new-password" (T-10-16)
* - Helper text + Fastmail app-password link (new tab, rel="noopener noreferrer") (T-10-15)
* - "Validating against CalDAV…" Loader2 spinner inline during mutation
* - CalDAV 400 failure copy, Save Credential / Cancel actions
* - Success: invalidates ['admin','members'] + ['me'] → needsProviderSetup refresh → SetupBanner unmounts
* - Escape closes; focus returns to trigger on close
* - 44px touch targets throughout
*
* Security:
* T-10-15: password never pre-filled, never logged, never stored beyond in-flight request
* T-10-16: autoComplete="new-password" prevents autofill of stored credential
*/
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
saveCredential,
saveMyCredential,
type SaveCredentialPayload,
type SaveMyCredentialPayload,
} from '../api/client.js';
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
interface CredentialSheetProps {
isOpen: boolean;
onClose: () => void;
/** Mode determines heading copy and which API endpoint is called */
mode: CredentialSheetMode;
/** The member being targeted (admin modes). For self-service, the current user's name. */
memberName: string | null;
/** The member's user id (admin modes only — ignored for self-service) */
memberId?: number;
/** Ref to the trigger element — focus returns here on close (a11y) */
triggerRef?: React.RefObject<HTMLElement | null>;
}
// ── Copywriting contract (UI-SPEC §Copywriting Contract) ───────────────────
function headingFor(mode: CredentialSheetMode): string {
if (mode === 'admin-rotate') return 'Rotate Credential';
if (mode === 'admin-add') return 'Add Credential';
return 'Add your calendar credential';
}
const HELPER_TEXT = 'Enter the Fastmail app password scoped to Calendars/CalDAV.';
const HELPER_LINK_HREF = 'https://app.fastmail.com/settings/security/devicetokens';
const HELPER_LINK_TEXT = 'Get an app password';
const HELPER_LINK_SUFFIX = " — choose the 'Calendars & Contacts (CalDAV)' scope.";
const VALIDATING_TEXT = 'Validating against CalDAV…';
const FAILURE_TEXT =
"Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.";
const SAVE_LABEL = 'Save Credential';
const CANCEL_LABEL = 'Cancel';
// ── Component ──────────────────────────────────────────────────────────────
export function CredentialSheet({
isOpen,
onClose,
mode,
memberName,
memberId,
triggerRef,
}: CredentialSheetProps) {
const queryClient = useQueryClient();
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
// Focus the heading/first focusable element on open (a11y)
const headingRef = useRef<HTMLHeadingElement>(null);
// Escape key closes the sheet (SettingsSheet pattern)
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
// Focus heading on open (a11y)
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
function handleClose() {
setPassword('');
setEmail('');
setValidationError(null);
onClose();
// Return focus to trigger element (a11y)
if (triggerRef?.current) {
triggerRef.current.focus();
}
}
const credentialMutation = useMutation({
mutationFn: async () => {
if (mode === 'self-service') {
const payload: SaveMyCredentialPayload = {
providerType: 'caldav',
fastmailEmail: email,
appPassword: password,
};
await saveMyCredential(payload);
} else {
if (!memberId) throw new Error('memberId required for admin modes');
const payload: SaveCredentialPayload = {
userId: memberId,
providerType: 'caldav',
fastmailEmail: email,
appPassword: password,
};
await saveCredential(payload);
}
},
onSuccess: () => {
// Invalidate both caches: admin member list + /api/me (needsProviderSetup refresh)
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
void queryClient.invalidateQueries({ queryKey: ['me'] });
handleClose();
},
onError: () => {
setValidationError(FAILURE_TEXT);
},
});
const handleSave = () => {
setValidationError(null);
credentialMutation.mutate();
};
if (!isOpen) return null;
const heading = headingFor(mode);
const isPending = credentialMutation.isPending;
const saveDisabled = isPending || password.trim().length === 0 || email.trim().length === 0;
const phone = window.matchMedia('(max-width: 767px)').matches;
return (
<>
{/* Backdrop */}
<div
onClick={handleClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.4)',
zIndex: 300,
}}
/>
{/* Sheet */}
<div
role="dialog"
aria-modal="true"
aria-label={heading}
style={
phone
? {
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface)',
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)',
}
: {
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)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
}
}
>
{/* Heading */}
<h2
ref={headingRef}
tabIndex={-1}
style={{
margin: '0 0 var(--space-1, 4px) 0',
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary)',
outline: 'none',
}}
>
{heading}
</h2>
{/* Member subtitle */}
{memberName && (
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-6, 24px)',
}}
>
{memberName}
</div>
)}
{/* Email field */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="credential-email"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Fastmail email
</label>
<input
id="credential-email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@fastmail.com"
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${validationError ? 'var(--color-destructive, #DC2626)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
}}
/>
</div>
{/* Password field */}
<div style={{ marginBottom: 'var(--space-2, 8px)' }}>
<label
htmlFor="credential-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
App password
</label>
<input
id="credential-password"
type="password"
autoComplete="new-password"
// NEVER pre-filled — T-10-16: existing credential is never fetched to client
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-describedby="credential-helper"
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${validationError ? 'var(--color-destructive, #DC2626)' : 'var(--color-border)'}`,
borderRadius: 'var(--space-1, 4px)',
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
}}
/>
</div>
{/* Helper text / validation state */}
<div
id="credential-helper"
style={{
fontSize: 'var(--text-label-size, 13px)',
color: validationError
? 'var(--color-destructive, #DC2626)'
: 'var(--color-text-secondary)',
lineHeight: 1.4,
marginBottom: 'var(--space-6, 24px)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
}}
>
{isPending ? (
<>
<Loader2
size={16}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
{VALIDATING_TEXT}
</>
) : validationError ? (
validationError
) : (
<>
{HELPER_TEXT}{' '}
<a
href={HELPER_LINK_HREF}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-member-0, #4A90D9)',
textDecoration: 'underline',
}}
>
{HELPER_LINK_TEXT}
</a>
{HELPER_LINK_SUFFIX}
</>
)}
</div>
{/* Actions row — right-aligned, Cancel + Save */}
<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)',
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-4, 16px)',
fontFamily: 'var(--font-family-base)',
borderRadius: 'var(--space-1, 4px)',
opacity: isPending ? 0.5 : 1,
}}
>
{CANCEL_LABEL}
</button>
<button
type="button"
onClick={handleSave}
disabled={saveDisabled}
style={{
background: saveDisabled
? 'var(--color-border, #E2E4E9)'
: 'var(--color-member-0, #4A90D9)',
color: '#ffffff',
border: 'none',
cursor: saveDisabled ? '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',
}}
>
{SAVE_LABEL}
</button>
</div>
</div>
</>
);
}