Phase 20: Admin Member Editor & Form Declutter #25
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* MemberEditorSheet — unified member editor sheet (Phase 20, D-05, D-06, D-07).
|
||||
*
|
||||
* Single component with a `mode` prop:
|
||||
* - 'edit' — three per-section saves: Profile, Set new password, App password
|
||||
* - 'create' — single form: display name, username, initial password, confirm
|
||||
*
|
||||
* UI-SPEC §Surface B (20-UI-SPEC.md):
|
||||
* - role="dialog", aria-modal, useFocusTrap, Escape closes, focus returns to trigger
|
||||
* - Phone: fixed bottom bottom-sheet; Desktop: centered modal (480px)
|
||||
* - zIndex 301 / backdrop 300 / overlay rgba(0,0,0,0.32)
|
||||
* - Per-section saves keep the sheet open; create-mode save closes it
|
||||
* - Admin toggle: role="switch", aria-checked (Accessibility Contract)
|
||||
*
|
||||
* Security:
|
||||
* T-20-07: password/app-password fields are write-only: never prefilled,
|
||||
* autoComplete="new-password", never logged (T-10-15/16 preserved)
|
||||
* T-20-08: app-password save routes through saveCredential → server-side CalDAV
|
||||
* validation; invalid password surfaces copy, nothing stored
|
||||
* T-20-09: admin toggle is cosmetic; D-03 409 guard enforced server-side (Plan 20-01)
|
||||
*
|
||||
* Retired copy (D-06): per-row credential buttons and standalone password-reset button removed from this phase.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
updateMemberProfile,
|
||||
fetchAdminResetPassword,
|
||||
fetchCreateMember,
|
||||
saveCredential,
|
||||
type AdminMember,
|
||||
} from '../api/client.js';
|
||||
import { useIsPhone } from '../hooks/useIsPhone.js';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap.js';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MemberEditorSheetMode = 'edit' | 'create';
|
||||
|
||||
interface MemberEditorSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: MemberEditorSheetMode;
|
||||
/** Present in edit mode; absent in create mode */
|
||||
member?: AdminMember;
|
||||
/** Ref to the trigger element — focus returns here on close (a11y) */
|
||||
triggerRef?: React.RefObject<HTMLElement | null>;
|
||||
/** Lift toast copy up to AdminPage which owns the toast state */
|
||||
onToast: (message: string) => void;
|
||||
}
|
||||
|
||||
// ── Copywriting (UI-SPEC §Copywriting Contract) ───────────────────────────
|
||||
|
||||
function headingFor(mode: MemberEditorSheetMode): string {
|
||||
return mode === 'edit' ? 'Edit member' : 'Add member';
|
||||
}
|
||||
|
||||
const CALDAV_FAILURE_TEXT =
|
||||
"Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.";
|
||||
const CALDAV_VALIDATING_TEXT = 'Validating against CalDAV…';
|
||||
const CALDAV_LINK_HREF = 'https://app.fastmail.com/settings/security/devicetokens';
|
||||
const CALDAV_LINK_TEXT = 'Get an app password';
|
||||
|
||||
// ── Shared field style helpers ─────────────────────────────────────────────
|
||||
|
||||
const inputStyle = (hasError: boolean): React.CSSProperties => ({
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${hasError ? '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',
|
||||
minHeight: '44px',
|
||||
});
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
};
|
||||
|
||||
const sectionLabelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
};
|
||||
|
||||
const sectionDividerStyle: React.CSSProperties = {
|
||||
borderTop: '1px solid var(--color-border-subtle)',
|
||||
margin: 'var(--space-6, 24px) 0',
|
||||
};
|
||||
|
||||
const fieldContainerStyle: React.CSSProperties = {
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
};
|
||||
|
||||
const inlineErrorStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive, #DC2626)',
|
||||
marginTop: 'var(--space-2, 8px)',
|
||||
};
|
||||
|
||||
const helperTextStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
function primaryButtonStyle(disabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
background: disabled ? 'var(--color-border, #E2E4E9)' : 'var(--color-member-0, #e8915a)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: disabled ? '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',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
};
|
||||
}
|
||||
|
||||
const cancelButtonStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: '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)',
|
||||
};
|
||||
|
||||
const actionsRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
marginTop: 'var(--space-4, 16px)',
|
||||
};
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function MemberEditorSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
member,
|
||||
triggerRef,
|
||||
onToast,
|
||||
}: MemberEditorSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const phone = useIsPhone();
|
||||
|
||||
// Focus management refs
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const handleDialogKeyDown = useFocusTrap(dialogRef);
|
||||
|
||||
// ── Profile section state ────────────────────────────────────────────────
|
||||
const [displayName, setDisplayName] = useState(member?.displayName ?? '');
|
||||
const [isAdmin, setIsAdmin] = useState(member?.isAdmin ?? false);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
|
||||
// ── Set new password section state ──────────────────────────────────────
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
|
||||
// ── App password section state ───────────────────────────────────────────
|
||||
// NOTE: GET /api/admin/members does NOT return fastmailEmail; the stored
|
||||
// fastmail_email lives only on the member_credentials row, which is not
|
||||
// surfaced to the admin list. So the email field starts BLANK on edit —
|
||||
// the admin must re-enter it. This is intentional (see D-CONTEXT note).
|
||||
const [fastmailEmail, setFastmailEmail] = useState('');
|
||||
const [appPassword, setAppPassword] = useState('');
|
||||
const [appPasswordError, setAppPasswordError] = useState<string | null>(null);
|
||||
|
||||
// ── Create mode state ───────────────────────────────────────────────────
|
||||
const [createDisplayName, setCreateDisplayName] = useState('');
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createConfirmPassword, setCreateConfirmPassword] = useState('');
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// ── handleClose ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Reset all form state
|
||||
setDisplayName(member?.displayName ?? '');
|
||||
setIsAdmin(member?.isAdmin ?? false);
|
||||
setProfileError(null);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError(null);
|
||||
setFastmailEmail('');
|
||||
setAppPassword('');
|
||||
setAppPasswordError(null);
|
||||
setCreateDisplayName('');
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateConfirmPassword('');
|
||||
setCreateError(null);
|
||||
onClose();
|
||||
// Return focus to trigger element (a11y)
|
||||
if (triggerRef?.current) {
|
||||
triggerRef.current.focus();
|
||||
}
|
||||
}, [onClose, triggerRef, member?.displayName, member?.isAdmin]);
|
||||
|
||||
// Sync profile state when member changes (different row opened)
|
||||
useEffect(() => {
|
||||
setDisplayName(member?.displayName ?? '');
|
||||
setIsAdmin(member?.isAdmin ?? false);
|
||||
setProfileError(null);
|
||||
}, [member?.id, member?.displayName, member?.isAdmin]);
|
||||
|
||||
// Escape key closes the sheet
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
// Focus heading on open (a11y)
|
||||
useEffect(() => {
|
||||
if (isOpen && headingRef.current) {
|
||||
headingRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// ── Profile mutation (Section 1) ─────────────────────────────────────────
|
||||
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
await updateMemberProfile(member.id, {
|
||||
displayName: displayName.trim(),
|
||||
isAdmin,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
onToast('Profile saved.');
|
||||
// Sheet stays open — per-section save (D-05)
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'last-admin') {
|
||||
// D-03 guard: revert toggle to previous value (member.isAdmin was true)
|
||||
setIsAdmin(member?.isAdmin ?? true);
|
||||
setProfileError('Cannot remove admin — at least one admin must remain.');
|
||||
} else {
|
||||
setProfileError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Set new password mutation (Section 2) ────────────────────────────────
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||
if (newPassword.length < 8) throw new Error('short');
|
||||
await fetchAdminResetPassword(member.id, newPassword);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError(null);
|
||||
onToast('Password updated.');
|
||||
// Sheet stays open — per-section save (D-05)
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setPasswordError('Passwords do not match.');
|
||||
} else if (msg === 'short') {
|
||||
setPasswordError('Password must be at least 8 characters.');
|
||||
} else {
|
||||
setPasswordError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── App password mutation (Section 3) ────────────────────────────────────
|
||||
|
||||
const appPasswordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!member) throw new Error('no-member');
|
||||
await saveCredential({
|
||||
userId: member.id,
|
||||
providerType: 'caldav',
|
||||
fastmailEmail: fastmailEmail.trim(),
|
||||
appPassword,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setFastmailEmail('');
|
||||
setAppPassword('');
|
||||
setAppPasswordError(null);
|
||||
onToast('App password saved.');
|
||||
// Sheet stays open — per-section save (D-05)
|
||||
},
|
||||
onError: () => {
|
||||
setAppPasswordError(CALDAV_FAILURE_TEXT);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Create member mutation ────────────────────────────────────────────────
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (createPassword !== createConfirmPassword) throw new Error('mismatch');
|
||||
if (createPassword.length < 8) throw new Error('short');
|
||||
await fetchCreateMember({
|
||||
displayName: createDisplayName.trim(),
|
||||
username: createUsername.trim(),
|
||||
password: createPassword,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
onToast('Member added.');
|
||||
// Create mode closes on success (D-07)
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setCreateError('Passwords do not match.');
|
||||
} else if (msg === 'short') {
|
||||
setCreateError('Password must be at least 8 characters.');
|
||||
} else if (msg === 'conflict' || msg.includes('409')) {
|
||||
setCreateError('That username is already in use. Choose a different one.');
|
||||
} else {
|
||||
setCreateError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Render guard ──────────────────────────────────────────────────────────
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const heading = headingFor(mode);
|
||||
|
||||
// ── Sheet style ───────────────────────────────────────────────────────────
|
||||
|
||||
const sheetStyle: React.CSSProperties = 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)',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop — uses --color-overlay per UI-SPEC §Surface B (matches ResetPasswordSheet) */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||
zIndex: 300,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={heading}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
style={sheetStyle}
|
||||
>
|
||||
{/* 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 (edit mode only) */}
|
||||
{mode === 'edit' && member && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{member.displayName ?? 'Member'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── EDIT MODE ──────────────────────────────────────────────────── */}
|
||||
{mode === 'edit' && member && (
|
||||
<>
|
||||
{/* Section 1 — Profile */}
|
||||
<div>
|
||||
<div style={sectionLabelStyle}>Profile</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="editor-display-name" style={labelStyle}>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="editor-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
aria-describedby={profileError ? 'profile-error' : undefined}
|
||||
style={inputStyle(!!profileError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admin toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Can access admin settings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle pill — role="switch" per UI-SPEC Accessibility Contract */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isAdmin}
|
||||
aria-label="Admin"
|
||||
onClick={() => {
|
||||
setProfileError(null);
|
||||
setIsAdmin((prev) => !prev);
|
||||
}}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '24px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isAdmin
|
||||
? 'var(--color-member-0, #e8915a)'
|
||||
: 'var(--color-border, #E2E4E9)',
|
||||
position: 'relative',
|
||||
transition: 'background 0.15s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Thumb */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: isAdmin ? '22px' : '2px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: '#ffffff',
|
||||
transition: 'left 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Last-admin guard inline error */}
|
||||
{profileError && (
|
||||
<div id="profile-error" style={inlineErrorStyle}>
|
||||
{profileError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={actionsRowStyle}>
|
||||
<button type="button" onClick={handleClose} style={cancelButtonStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profileMutation.isPending || displayName.trim().length === 0}
|
||||
onClick={() => {
|
||||
setProfileError(null);
|
||||
profileMutation.mutate();
|
||||
}}
|
||||
style={primaryButtonStyle(
|
||||
profileMutation.isPending || displayName.trim().length === 0,
|
||||
)}
|
||||
>
|
||||
{profileMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2 — Set new password (only for members with local credential) */}
|
||||
{member.hasLocalCredential && (
|
||||
<>
|
||||
<div style={sectionDividerStyle} />
|
||||
<div>
|
||||
<div style={sectionLabelStyle}>Set new password</div>
|
||||
<div style={helperTextStyle}>Leave blank to keep the current password.</div>
|
||||
|
||||
{/* New password */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="editor-new-password" style={labelStyle}>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="editor-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
style={inputStyle(!!passwordError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm new password */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="editor-confirm-password" style={labelStyle}>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="editor-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
aria-describedby={passwordError ? 'password-error' : undefined}
|
||||
style={inputStyle(!!passwordError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password error */}
|
||||
{passwordError && (
|
||||
<div id="password-error" style={inlineErrorStyle}>
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={actionsRowStyle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
passwordMutation.isPending ||
|
||||
newPassword.length === 0 ||
|
||||
confirmPassword.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
setPasswordError(null);
|
||||
passwordMutation.mutate();
|
||||
}}
|
||||
style={primaryButtonStyle(
|
||||
passwordMutation.isPending ||
|
||||
newPassword.length === 0 ||
|
||||
confirmPassword.length === 0,
|
||||
)}
|
||||
>
|
||||
{passwordMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Set password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Section 3 — App password (always in edit mode) */}
|
||||
<>
|
||||
<div style={sectionDividerStyle} />
|
||||
<div>
|
||||
<div style={sectionLabelStyle}>App password</div>
|
||||
|
||||
{/* Helper text with link */}
|
||||
<div style={{ ...helperTextStyle, marginBottom: 'var(--space-4, 16px)' }}>
|
||||
Fastmail app password scoped to Calendars & Contacts (CalDAV).{' '}
|
||||
<a
|
||||
href={CALDAV_LINK_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--color-member-0, #e8915a)',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{CALDAV_LINK_TEXT}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* In-flight validating state */}
|
||||
{appPasswordMutation.isPending && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
<Loader2
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
{CALDAV_VALIDATING_TEXT}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fastmail email */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="editor-fastmail-email" style={labelStyle}>
|
||||
Fastmail email
|
||||
</label>
|
||||
<input
|
||||
id="editor-fastmail-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={fastmailEmail}
|
||||
onChange={(e) => setFastmailEmail(e.target.value)}
|
||||
placeholder="user@fastmail.com"
|
||||
aria-describedby={appPasswordError ? 'app-password-error' : undefined}
|
||||
style={inputStyle(!!appPasswordError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App password field — NEVER prefilled (T-20-07) */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="editor-app-password" style={labelStyle}>
|
||||
App password
|
||||
</label>
|
||||
<input
|
||||
id="editor-app-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={appPassword}
|
||||
onChange={(e) => setAppPassword(e.target.value)}
|
||||
aria-describedby={appPasswordError ? 'app-password-error' : undefined}
|
||||
style={inputStyle(!!appPasswordError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CalDAV error */}
|
||||
{appPasswordError && (
|
||||
<div id="app-password-error" style={inlineErrorStyle}>
|
||||
{appPasswordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={actionsRowStyle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
appPasswordMutation.isPending ||
|
||||
fastmailEmail.trim().length === 0 ||
|
||||
appPassword.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
setAppPasswordError(null);
|
||||
appPasswordMutation.mutate();
|
||||
}}
|
||||
style={primaryButtonStyle(
|
||||
appPasswordMutation.isPending ||
|
||||
fastmailEmail.trim().length === 0 ||
|
||||
appPassword.length === 0,
|
||||
)}
|
||||
>
|
||||
{appPasswordMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Save app password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── CREATE MODE ────────────────────────────────────────────────── */}
|
||||
{mode === 'create' && (
|
||||
<div>
|
||||
{/* Display name */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="create-display-name" style={labelStyle}>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="create-display-name"
|
||||
type="text"
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
aria-describedby={createError ? 'create-error' : undefined}
|
||||
style={inputStyle(!!createError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="create-username" style={labelStyle}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="create-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
aria-describedby={createError ? 'create-error' : undefined}
|
||||
style={inputStyle(!!createError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Initial password */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="create-initial-password" style={labelStyle}>
|
||||
Initial password
|
||||
</label>
|
||||
<input
|
||||
id="create-initial-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
aria-describedby={createError ? 'create-error' : undefined}
|
||||
style={inputStyle(!!createError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div style={fieldContainerStyle}>
|
||||
<label htmlFor="create-confirm-password" style={labelStyle}>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="create-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createConfirmPassword}
|
||||
onChange={(e) => setCreateConfirmPassword(e.target.value)}
|
||||
aria-describedby={createError ? 'create-error' : undefined}
|
||||
style={inputStyle(!!createError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create error */}
|
||||
{createError && (
|
||||
<div id="create-error" style={inlineErrorStyle}>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={actionsRowStyle}>
|
||||
<button type="button" onClick={handleClose} style={cancelButtonStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
createDisplayName.trim().length === 0 ||
|
||||
createUsername.trim().length === 0 ||
|
||||
createPassword.length === 0 ||
|
||||
createConfirmPassword.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
setCreateError(null);
|
||||
createMutation.mutate();
|
||||
}}
|
||||
style={primaryButtonStyle(
|
||||
createMutation.isPending ||
|
||||
createDisplayName.trim().length === 0 ||
|
||||
createUsername.trim().length === 0 ||
|
||||
createPassword.length === 0 ||
|
||||
createConfirmPassword.length === 0,
|
||||
)}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user