feat(10-04): add CredentialSheet and SetupBanner components
- CredentialSheet: admin-rotate/admin-add/self-service modes, role=dialog, aria-modal - Password field type=password autoComplete=new-password, never pre-filled (T-10-16) - Fastmail link target=_blank rel=noopener noreferrer (UI-SPEC Surface 3) - Loader2 spinner + CalDAV failure copy on mutation error - Success invalidates ['admin','members'] + ['me'] → SetupBanner unmounts - Escape closes, focus returns to trigger (a11y) - SetupBanner: renders on needsProviderSetup=true only, role=status aria-live=polite - KeyRound icon, 'Set up your calendar' heading, 'Set up now' CTA (no X/dismiss) - Success-only dismissal: ['me'] invalidation is the ONLY code path to hide the banner - All styling via var(--token); 44px touch targets throughout
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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={{
|
||||
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)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user