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:
+134
-680
@@ -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,249 +364,80 @@ 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>
|
|
||||||
|
|
||||||
{/* Surface 11A — Add member inline form */}
|
|
||||||
<div
|
|
||||||
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={{
|
|
||||||
fontSize: 'var(--text-body-size, 15px)',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
marginBottom: 'var(--space-4, 16px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add member
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
|
ref={addMemberTriggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={createSubmitDisabled}
|
onClick={openEditorForCreate}
|
||||||
onClick={() => {
|
|
||||||
setCreateError(null);
|
|
||||||
createMemberMutation.mutate();
|
|
||||||
}}
|
|
||||||
style={{
|
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',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 'var(--space-2, 8px)',
|
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)',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--color-surface-dim)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--color-surface)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createMemberMutation.isPending && (
|
<Plus size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
|
||||||
<Loader2
|
|
||||||
size={14}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
Add member
|
Add member
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/* end admin-panel-members */}
|
{/* end admin-panel-members */}
|
||||||
@@ -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) */}
|
|
||||||
{member.hasLocalCredential && onResetPassword && (
|
|
||||||
<button
|
|
||||||
ref={resetBtnRef}
|
|
||||||
type="button"
|
|
||||||
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>
|
|
||||||
|
{/* Trailing chevron affordance */}
|
||||||
|
<ChevronRight
|
||||||
|
size={16}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: 'var(--color-text-muted)', flexShrink: 0, marginLeft: 'var(--space-2)' }}
|
||||||
|
/>
|
||||||
</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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user