feat(20-03): rework AdminPage MemberRow + Add-member trigger; remove old surfaces

- MemberRow: whole-row role=button, aria-label='Edit {name}', trailing ChevronRight
- Admin badge on member.isAdmin (12px/600, --color-member-0 on --color-surface-dim)
- 'Add member' ghost trigger with Plus icon opens MemberEditorSheet in create mode
- Single MemberEditorSheet replaces CredentialSheet + ResetPasswordSheet mounts
- Removed: inline Local Accounts add-form, ResetPasswordSheet definition, create-form state
- Empty state: 'No members yet' / 'Add a member to get started.' (UI-SPEC copy)
This commit is contained in:
Lucas Berger
2026-06-18 17:35:37 -04:00
parent b125a69b58
commit 9e6b004541
+140 -686
View File
@@ -9,10 +9,10 @@
* - Centered content column, maxWidth 640px on desktop * - Centered content column, maxWidth 640px on desktop
* - var(--space-12) top/bottom padding, var(--space-6) horizontal padding * - var(--space-12) top/bottom padding, var(--space-6) horizontal padding
* *
* UI-SPEC §Surface 2 (MEMBERS section): * UI-SPEC §Surface A (MEMBERS section — Phase 20):
* - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge * - Tappable MemberRow with trailing ChevronRight + "Admin" badge when isAdmin
* - "Rotate" or "Add credential" action button per hasCredential * - Single "Add member" ghost trigger at the bottom (opens MemberEditorSheet in create mode)
* - Opens CredentialSheet in admin-rotate or admin-add mode * - No per-row action-button cluster; no always-open inline add form
* *
* UI-SPEC §Surface 5 (SHARED CALENDAR section): * UI-SPEC §Surface 5 (SHARED CALENDAR section):
* - Radio group, one row per synced calendar * - Radio group, one row per synced calendar
@@ -25,21 +25,18 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { CheckCircle, AlertCircle, ChevronRight, Plus } from 'lucide-react';
import { import {
fetchAdminMembers, fetchAdminMembers,
fetchAdminCalendars, fetchAdminCalendars,
setSharedCalendar, setSharedCalendar,
fetchAdminTimezone, fetchAdminTimezone,
setAdminTimezone, setAdminTimezone,
fetchCreateMember,
fetchAdminResetPassword,
type AdminMember, type AdminMember,
type AdminCalendar, type AdminCalendar,
} from '../api/client.js'; } from '../api/client.js';
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js'; import { MemberEditorSheet } from '../components/MemberEditorSheet.js';
import { useIsPhone } from '../hooks/useIsPhone.js'; import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
// ── Styles ───────────────────────────────────────────────────────────────── // ── Styles ─────────────────────────────────────────────────────────────────
@@ -78,24 +75,16 @@ export function AdminPage() {
// Two-tab navigation state (D-10) // Two-tab navigation state (D-10)
const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members'); const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members');
// Credential sheet state // ── Member editor sheet state ───────────────────────────────────────────
const [sheetOpen, setSheetOpen] = useState(false); // A single MemberEditorSheet instance handles both edit (row tap) and
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add'); // create ("Add member" trigger) modes (D-07 unification).
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null); const [editorOpen, setEditorOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null); const [editorMode, setEditorMode] = useState<'edit' | 'create'>('edit');
const [editorMember, setEditorMember] = useState<AdminMember | null>(null);
// Reset-password sheet state (Surface 11B) // Ref to the element that opened the sheet — focus returns here on close (a11y)
const [resetSheetOpen, setResetSheetOpen] = useState(false); const editorTriggerRef = useRef<HTMLElement | null>(null);
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null); // Ref for the "Add member" ghost trigger button
// resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close const addMemberTriggerRef = useRef<HTMLButtonElement>(null);
const resetTriggerRef = useRef<HTMLButtonElement | null>(null);
// Create-member form state (Surface 11A)
const [createDisplayName, setCreateDisplayName] = useState('');
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createConfirmPassword, setCreateConfirmPassword] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
// Shared calendar picker state // Shared calendar picker state
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null); const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
@@ -248,62 +237,21 @@ export function AdminPage() {
)?.focus(); )?.focus();
} }
// Open credential sheet for a member // Open the editor for a specific member (row tap → edit mode)
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) { function openEditorForMember(member: AdminMember, rowEl: HTMLElement) {
// Capture the button so focus can return on close editorTriggerRef.current = rowEl;
(triggerRef as React.MutableRefObject<HTMLElement | null>).current = buttonRef.current; setEditorMember(member);
setSheetMember(member); setEditorMode('edit');
setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add'); setEditorOpen(true);
setSheetOpen(true);
} }
// Create-member mutation (Surface 11A) // Open the editor in create mode ("Add member" trigger)
const createMemberMutation = useMutation({ function openEditorForCreate() {
mutationFn: async () => { editorTriggerRef.current = addMemberTriggerRef.current;
// Client-side validation (server also validates; this is for UX) setEditorMember(null);
if (createPassword !== createConfirmPassword) { setEditorMode('create');
throw new Error('mismatch'); setEditorOpen(true);
} }
if (createPassword.length < 8) {
throw new Error('short');
}
await fetchCreateMember({
displayName: createDisplayName.trim(),
username: createUsername.trim(),
password: createPassword,
});
},
onSuccess: () => {
// Clear form + refresh member list
setCreateDisplayName('');
setCreateUsername('');
setCreatePassword('');
setCreateConfirmPassword('');
setCreateError(null);
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
void queryClient.invalidateQueries({ queryKey: ['me'] });
showToast('Member added.');
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setCreateError('Passwords do not match.');
} else if (msg === 'short') {
setCreateError('Password is too short. Use 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.');
}
},
});
const createSubmitDisabled =
createMemberMutation.isPending ||
createDisplayName.trim().length === 0 ||
createUsername.trim().length === 0 ||
createPassword.length === 0 ||
createConfirmPassword.length === 0;
const saveDisabled = const saveDisabled =
sharedCalMutation.isPending || sharedCalMutation.isPending ||
@@ -416,248 +364,79 @@ export function AdminPage() {
</div> </div>
)} )}
{membersQuery.data && ( {membersQuery.data && membersQuery.data.members.length === 0 && (
<div
style={{
padding: 'var(--space-8, 32px) 0',
textAlign: 'center',
}}
>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-2, 8px)',
}}
>
No members yet
</div>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-secondary)',
}}
>
Add a member to get started.
</div>
</div>
)}
{membersQuery.data && membersQuery.data.members.length > 0 && (
<div> <div>
{membersQuery.data.members.map((member, idx) => ( {membersQuery.data.members.map((member, idx) => (
<MemberRow <MemberRow
key={member.id} key={member.id}
member={member} member={member}
colorIndex={idx} colorIndex={idx}
onAction={(buttonRef) => openSheet(member, buttonRef)} onEdit={(rowEl) => openEditorForMember(member, rowEl)}
onResetPassword={(buttonRef) => {
// Capture trigger button so focus can return on close
resetTriggerRef.current = buttonRef.current;
setResetTargetMember(member);
setResetSheetOpen(true);
}}
/> />
))} ))}
</div> </div>
)} )}
</section>
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */} {/* "Add member" ghost trigger — opens MemberEditorSheet in create mode (D-07) */}
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}> <div style={{ marginTop: 'var(--space-4, 16px)' }}>
<div style={sectionLabelStyle}>Local Accounts</div> <button
ref={addMemberTriggerRef}
{/* Surface 11A — Add member inline form */} type="button"
<div onClick={openEditorForCreate}
style={{
border: '1px solid var(--color-border-subtle, var(--color-border))',
borderRadius: '8px',
padding: 'var(--space-4, 16px)',
marginBottom: 'var(--space-6, 24px)',
}}
>
<div
style={{ style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
width: '100%',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
minHeight: '44px',
cursor: 'pointer',
fontSize: 'var(--text-body-size, 15px)', fontSize: 'var(--text-body-size, 15px)',
fontWeight: 600, fontWeight: 400,
color: 'var(--color-text-primary)', color: 'var(--color-text-primary)',
marginBottom: 'var(--space-4, 16px)', fontFamily: 'var(--font-family-base)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-surface-dim)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-surface)';
}} }}
> >
<Plus size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
Add member Add member
</div> </button>
{/* Display name */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-display-name"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Display name
</label>
<input
id="admin-create-display-name"
type="text"
value={createDisplayName}
onChange={(e) => setCreateDisplayName(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid 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',
}}
/>
</div>
{/* Username */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-username"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Username
</label>
<input
id="admin-create-username"
type="text"
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid 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',
}}
/>
</div>
{/* Initial password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="admin-create-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Initial password
</label>
<input
id="admin-create-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid 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',
}}
/>
</div>
{/* Confirm password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="admin-create-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm password
</label>
<input
id="admin-create-confirm-password"
type="password"
autoComplete="new-password"
value={createConfirmPassword}
onChange={(e) => setCreateConfirmPassword(e.target.value)}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: '1px solid 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',
}}
/>
</div>
{/* Inline error */}
{createError && (
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive)',
marginBottom: 'var(--space-3, 12px)',
}}
>
{createError}
</div>
)}
{/* Action row */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={createSubmitDisabled}
onClick={() => {
setCreateError(null);
createMemberMutation.mutate();
}}
style={{
background: createSubmitDisabled
? 'var(--color-border, #e2e4e9)'
: 'var(--color-member-0, #4a90d9)',
color: '#ffffff',
border: 'none',
cursor: createSubmitDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2, 8px)',
}}
>
{createMemberMutation.isPending && (
<Loader2
size={14}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
/>
)}
Add member
</button>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -1108,33 +887,15 @@ export function AdminPage() {
</div> </div>
)} )}
{/* Credential sheet — admin-rotate or admin-add */} {/* Single unified MemberEditorSheet — handles edit + create modes (D-07) */}
{sheetMember && ( <MemberEditorSheet
<CredentialSheet isOpen={editorOpen}
isOpen={sheetOpen} onClose={() => setEditorOpen(false)}
onClose={() => setSheetOpen(false)} mode={editorMode}
mode={sheetMode} member={editorMember ?? undefined}
memberName={sheetMember.displayName} triggerRef={editorTriggerRef}
memberId={sheetMember.id} onToast={showToast}
triggerRef={triggerRef} />
/>
)}
{/* Surface 11B — Reset password sheet */}
{resetTargetMember && (
<ResetPasswordSheet
isOpen={resetSheetOpen}
onClose={() => {
setResetSheetOpen(false);
// Return focus to trigger
if (resetTriggerRef.current) {
resetTriggerRef.current.focus();
}
}}
onSuccess={() => showToast('Password reset.')}
member={resetTargetMember}
/>
)}
</div> </div>
); );
} }
@@ -1144,16 +905,37 @@ export function AdminPage() {
interface MemberRowProps { interface MemberRowProps {
member: AdminMember; member: AdminMember;
colorIndex: number; colorIndex: number;
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void; /** Called with the row element so focus can return to it on editor close */
onResetPassword?: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void; onEdit: (rowEl: HTMLElement) => void;
} }
function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) { /**
const buttonRef = useRef<HTMLButtonElement>(null); * Tappable member row (D-04): whole-row role="button" target with trailing
const resetBtnRef = useRef<HTMLButtonElement>(null); * ChevronRight affordance. Opens MemberEditorSheet in edit mode on tap/click
* or Enter/Space. Admin badge displayed when member.isAdmin.
*/
function MemberRow({ member, colorIndex, onEdit }: MemberRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
function handleActivate() {
if (rowRef.current) {
onEdit(rowRef.current);
}
}
return ( return (
<div <div
ref={rowRef}
role="button"
aria-label={`Edit ${member.displayName ?? 'Member'}`}
tabIndex={0}
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleActivate();
}
}}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -1161,6 +943,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP
minHeight: '44px', minHeight: '44px',
padding: 'var(--space-2, 8px) 0', padding: 'var(--space-2, 8px) 0',
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))', borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
cursor: 'pointer',
}} }}
> >
{/* Avatar swatch */} {/* Avatar swatch */}
@@ -1175,7 +958,7 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP
}} }}
/> />
{/* Name + status */} {/* Name + credential status badge */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
style={{ style={{
@@ -1235,54 +1018,29 @@ function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowP
</div> </div>
</div> </div>
{/* Action button row */} {/* Admin badge — shown when member.isAdmin (UI-SPEC §Surface A) */}
<div style={{ display: 'flex', gap: 'var(--space-2, 8px)', flexShrink: 0 }}> {member.isAdmin && (
{/* Credential rotate/add button */} <span
<button
ref={buttonRef}
type="button"
onClick={() => onAction(buttonRef)}
style={{ style={{
background: 'none', fontSize: '12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600, fontWeight: 600,
color: 'var(--color-text-primary)', color: 'var(--color-member-0, #e8915a)',
minHeight: '44px', background: 'var(--color-surface-dim, #f7f7f8)',
minWidth: '44px', borderRadius: '4px',
padding: '0 var(--space-3, 12px)', padding: '2px 6px',
fontFamily: 'var(--font-family-base)', flexShrink: 0,
}} }}
> >
{member.hasCredential ? 'Rotate' : 'Add credential'} Admin
</button> </span>
)}
{/* Surface 11B — Reset password button (only for members with a local credential) */} {/* Trailing chevron affordance */}
{member.hasLocalCredential && onResetPassword && ( <ChevronRight
<button size={16}
ref={resetBtnRef} aria-hidden="true"
type="button" style={{ color: 'var(--color-text-muted)', flexShrink: 0, marginLeft: 'var(--space-2)' }}
onClick={() => onResetPassword(resetBtnRef)} />
style={{
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-3, 12px)',
fontFamily: 'var(--font-family-base)',
}}
>
Reset password
</button>
)}
</div>
</div> </div>
); );
} }
@@ -1372,310 +1130,6 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr
); );
} }
// ── ResetPasswordSheet ──────────────────────────────────────────────────────
/**
* Surface 11B — Admin password reset sheet.
* Opens as a bottom sheet (mobile) / centered modal (desktop).
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger).
* No current-password field — admin reset does not require knowing the old password.
*/
interface ResetPasswordSheetProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
member: AdminMember;
}
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
// WR-05: resize-aware phone detection.
const sheetPhone = useIsPhone();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
// WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true").
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
// Escape closes the sheet
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 heading on open
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
function handleClose() {
setNewPassword('');
setConfirmPassword('');
setError(null);
onClose();
}
const resetMutation = useMutation({
mutationFn: async () => {
if (newPassword !== confirmPassword) throw new Error('mismatch');
await fetchAdminResetPassword(member.id, newPassword);
},
onSuccess: () => {
handleClose();
onSuccess?.();
},
onError: (err) => {
const msg = err instanceof Error ? err.message : 'server';
if (msg === 'mismatch') {
setError('Passwords do not match.');
} else {
setError('Something went wrong. Please try again.');
}
},
});
const isPending = resetMutation.isPending;
const submitDisabled = isPending || 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: 300,
}}
/>
{/* Sheet — phone: bottom-sheet / desktop: centered modal (D-09) */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Reset password"
onKeyDown={handleDialogKeyDown}
style={
sheetPhone
? {
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface, #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)',
}
: {
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, #ffffff)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
}
}
>
<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',
}}
>
Reset password
</h2>
{/* Member subtitle */}
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-6, 24px)',
}}
>
{member.displayName ?? 'Member'}
</div>
{/* New password */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<label
htmlFor="reset-new-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
New password
</label>
<input
id="reset-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 ? 'var(--color-destructive)' : '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',
}}
/>
</div>
{/* Confirm new password */}
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
<label
htmlFor="reset-confirm-password"
style={{
display: 'block',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-1, 4px)',
}}
>
Confirm new password
</label>
<input
id="reset-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
aria-describedby={error ? 'reset-error' : undefined}
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
border: `1px solid ${error ? 'var(--color-destructive)' : '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',
}}
/>
</div>
{/* Inline error */}
{error && (
<div
id="reset-error"
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-destructive)',
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)',
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);
resetMutation.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',
}}
>
Reset password
</button>
</div>
</div>
</>
);
}
// ── EmptyCalendarsState ───────────────────────────────────────────────────── // ── EmptyCalendarsState ─────────────────────────────────────────────────────
function EmptyCalendarsState() { function EmptyCalendarsState() {