Phase 20: Admin Member Editor & Form Declutter #25

Merged
luckberg merged 37 commits from gsd/phase-20-admin-member-editor-form-declutter into main 2026-06-18 20:40:35 -04:00
Showing only changes of commit b125a69b58 - Show all commits
@@ -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 &amp; 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>
</>
);
}