1711 lines
63 KiB
TypeScript
1711 lines
63 KiB
TypeScript
/**
|
|
* AdminPage — /admin route (D-02: dedicated gated route, not SettingsSheet extension).
|
|
*
|
|
* Non-admin users are redirected to /calendar at the App.tsx route level (UX, D-03).
|
|
* The server enforces 403 on every /api/admin/* request (Plan 03, requireAdmin).
|
|
*
|
|
* UI-SPEC §Surface 1 (/admin route):
|
|
* - "Admin Settings" heading (18px/600)
|
|
* - Centered content column, maxWidth 640px on desktop
|
|
* - var(--space-12) top/bottom padding, var(--space-6) horizontal padding
|
|
*
|
|
* UI-SPEC §Surface 2 (MEMBERS section):
|
|
* - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge
|
|
* - "Rotate" or "Add credential" action button per hasCredential
|
|
* - Opens CredentialSheet in admin-rotate or admin-add mode
|
|
*
|
|
* UI-SPEC §Surface 5 (SHARED CALENDAR section):
|
|
* - Radio group, one row per synced calendar
|
|
* - "Currently shared" label on active selection
|
|
* - Two-tap Save (disabled until selection differs from saved)
|
|
* - Empty state when no calendars synced
|
|
*
|
|
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
|
import {
|
|
fetchAdminMembers,
|
|
fetchAdminCalendars,
|
|
setSharedCalendar,
|
|
fetchAdminTimezone,
|
|
setAdminTimezone,
|
|
fetchCreateMember,
|
|
fetchAdminResetPassword,
|
|
type AdminMember,
|
|
type AdminCalendar,
|
|
} from '../api/client.js';
|
|
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
|
|
import { useIsPhone } from '../hooks/useIsPhone.js';
|
|
import { useFocusTrap } from '../hooks/useFocusTrap.js';
|
|
|
|
// ── Styles ─────────────────────────────────────────────────────────────────
|
|
|
|
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-2, 8px)',
|
|
};
|
|
|
|
// ── AdminPage ──────────────────────────────────────────────────────────────
|
|
|
|
export function AdminPage() {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Phone detection for toast bottom offset (WR-05: resize-aware)
|
|
const phone = useIsPhone();
|
|
|
|
// Success toast state (D-08).
|
|
// WR-03: store a unique id per toast so an identical repeated message still
|
|
// re-announces (aria-live re-fires on remount) and the 3s timer resets.
|
|
const [toast, setToast] = useState<{ id: number; msg: string } | null>(null);
|
|
const showToast = (msg: string) => setToast({ id: Date.now(), msg });
|
|
|
|
// Auto-dismiss toast after 3000ms — mirrors SyncStateToast lines 72-79.
|
|
// `toast` is a fresh object per showToast() call, so a repeated identical
|
|
// message produces a new reference here → the timer restarts (WR-03).
|
|
useEffect(() => {
|
|
if (!toast) return;
|
|
const timer = setTimeout(() => setToast(null), 3000);
|
|
return () => clearTimeout(timer);
|
|
}, [toast]);
|
|
|
|
// Two-tab navigation state (D-10)
|
|
const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members');
|
|
|
|
// Credential sheet state
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add');
|
|
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
|
|
// Reset-password sheet state (Surface 11B)
|
|
const [resetSheetOpen, setResetSheetOpen] = useState(false);
|
|
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
|
|
// resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close
|
|
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
|
|
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
|
|
|
// Timezone picker state
|
|
const [timezoneInput, setTimezoneInput] = useState<string | null>(null);
|
|
// Searchable combobox state: tzSearch is the live filter text while the list is
|
|
// open (null = closed, input shows the selected zone). tzActiveIndex tracks the
|
|
// keyboard-highlighted option.
|
|
const [tzOpen, setTzOpen] = useState(false);
|
|
const [tzSearch, setTzSearch] = useState<string | null>(null);
|
|
const [tzActiveIndex, setTzActiveIndex] = useState(0);
|
|
const tzBlurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// WR-07: clear any pending blur-close timer on unmount so it can't fire
|
|
// setState after the component is gone.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
// Members query
|
|
const membersQuery = useQuery({
|
|
queryKey: ['admin', 'members'],
|
|
queryFn: fetchAdminMembers,
|
|
retry: false,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
// Calendars query
|
|
const calendarsQuery = useQuery({
|
|
queryKey: ['admin', 'calendars'],
|
|
queryFn: fetchAdminCalendars,
|
|
retry: false,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
// Derive current saved shared calendar id from the data
|
|
const currentSharedId = calendarsQuery.data?.calendars.find((c) => c.isShared)?.id ?? null;
|
|
|
|
// Effective selected = user pick OR fallback to current saved
|
|
const effectiveSelected = selectedCalendarId ?? currentSharedId;
|
|
|
|
// Timezone query
|
|
const timezoneQuery = useQuery({
|
|
queryKey: ['admin', 'timezone'],
|
|
queryFn: fetchAdminTimezone,
|
|
retry: false,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
// Timezone save mutation
|
|
const timezoneMutation = useMutation({
|
|
mutationFn: (tz: string) => setAdminTimezone(tz),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
|
|
setTimezoneInput(null); // reset local override after save
|
|
},
|
|
});
|
|
|
|
// Detected browser timezone (D-02). IN-07: memoize — the resolved zone is
|
|
// stable for the session, no need to recompute every render.
|
|
const detectedTz = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);
|
|
|
|
// Effective timezone input value: local override → stored value → ''
|
|
const storedTimezone = timezoneQuery.data?.timezone ?? '';
|
|
const effectiveTimezoneInput = timezoneInput ?? storedTimezone;
|
|
|
|
// WR-01: derive isExplicit so we only apply the no-op guard when the timezone
|
|
// has ALREADY been explicitly saved. On first run (isExplicitlySet: false) the
|
|
// admin must be able to confirm/save the displayed system-default — even if the
|
|
// input value already matches the fallback string. Keep pending and empty-input
|
|
// guards unconditional.
|
|
const isExplicit = timezoneQuery.data?.isExplicitlySet ?? false;
|
|
|
|
// Save is disabled when:
|
|
// - mutation is in-flight (pending), OR
|
|
// - input is empty, OR
|
|
// - the timezone IS already explicitly set AND the input is unchanged (no-op)
|
|
const timezoneSaveDisabled =
|
|
timezoneMutation.isPending ||
|
|
effectiveTimezoneInput === '' ||
|
|
(isExplicit && effectiveTimezoneInput === storedTimezone);
|
|
|
|
// IANA zones list (Intl.supportedValuesOf may not be present in all runtimes)
|
|
const ianaZones: string[] =
|
|
typeof (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf ===
|
|
'function'
|
|
? (Intl as { supportedValuesOf: (key: string) => string[] }).supportedValuesOf('timeZone')
|
|
: [];
|
|
|
|
// Searchable combobox: filter zones by the live search text (case-insensitive,
|
|
// ignoring underscores so "new york" matches "America/New_York"). When the search
|
|
// is empty the full list shows — so tapping the field reveals every zone with no
|
|
// typing required.
|
|
const tzNorm = (s: string) => s.toLowerCase().replace(/_/g, ' ');
|
|
const tzQuery = tzOpen ? tzNorm(tzSearch ?? '') : '';
|
|
const filteredZones = tzQuery
|
|
? ianaZones.filter((tz) => tzNorm(tz).includes(tzQuery))
|
|
: ianaZones;
|
|
|
|
// WR-06: clamp the active index into the CURRENT filtered list every render.
|
|
// tzActiveIndex is reset to 0 on filter changes, but batched updates can leave
|
|
// it referencing an index past the end of a freshly-shrunk list for one render;
|
|
// aria-activedescendant and the visual highlight must both use this clamped value
|
|
// so the announced row and highlighted row never disagree.
|
|
const tzActiveIndexClamped = Math.min(tzActiveIndex, Math.max(0, filteredZones.length - 1));
|
|
|
|
// Commit a zone selection from the list, then close.
|
|
function selectTimezone(tz: string) {
|
|
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
|
|
setTimezoneInput(tz);
|
|
setTzSearch(null);
|
|
setTzOpen(false);
|
|
}
|
|
|
|
// Save shared calendar mutation
|
|
const sharedCalMutation = useMutation({
|
|
mutationFn: (calId: number) => setSharedCalendar(calId),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] });
|
|
// Also invalidate events so the shared lane updates
|
|
void queryClient.invalidateQueries({ queryKey: ['events'] });
|
|
setSelectedCalendarId(null); // reset picker
|
|
},
|
|
});
|
|
|
|
// Roving tabindex keyboard handler for the two-tab strip (D-10).
|
|
// WR-02: full WAI-ARIA tabs pattern — ArrowLeft/Right wrap around the ends,
|
|
// Home/End jump to the first/last tab.
|
|
function handleTabKeyDown(e: React.KeyboardEvent, current: 'members' | 'settings') {
|
|
const order = ['members', 'settings'] as const;
|
|
const idx = order.indexOf(current);
|
|
let next: (typeof order)[number] | null = null;
|
|
if (e.key === 'ArrowRight') {
|
|
next = order[(idx + 1) % order.length];
|
|
} else if (e.key === 'ArrowLeft') {
|
|
next = order[(idx - 1 + order.length) % order.length];
|
|
} else if (e.key === 'Home') {
|
|
next = order[0];
|
|
} else if (e.key === 'End') {
|
|
next = order[order.length - 1];
|
|
}
|
|
if (!next) return;
|
|
e.preventDefault();
|
|
setActiveTab(next);
|
|
(
|
|
e.currentTarget.parentElement?.querySelector(`[id="admin-tab-${next}"]`) as HTMLElement | null
|
|
)?.focus();
|
|
}
|
|
|
|
// Open credential sheet for a member
|
|
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) {
|
|
// Capture the button so focus can return on close
|
|
(triggerRef as React.MutableRefObject<HTMLElement | null>).current = buttonRef.current;
|
|
setSheetMember(member);
|
|
setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add');
|
|
setSheetOpen(true);
|
|
}
|
|
|
|
// Create-member mutation (Surface 11A)
|
|
const createMemberMutation = useMutation({
|
|
mutationFn: async () => {
|
|
// Client-side validation (server also validates; this is for UX)
|
|
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: () => {
|
|
// 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 =
|
|
sharedCalMutation.isPending ||
|
|
effectiveSelected === null ||
|
|
effectiveSelected === currentSharedId;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: '100%',
|
|
overflowY: 'auto',
|
|
fontFamily: 'var(--font-family-base)',
|
|
// Bottom padding to clear the 56px fixed tab bar on phone
|
|
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth: '640px',
|
|
margin: '0 auto',
|
|
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
|
|
}}
|
|
>
|
|
{/* Page heading */}
|
|
<h1
|
|
style={{
|
|
margin: '0 0 var(--space-6, 24px) 0',
|
|
fontSize: 'var(--text-heading-size, 18px)',
|
|
fontWeight: 600,
|
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
|
color: 'var(--color-text-primary)',
|
|
}}
|
|
>
|
|
Admin Settings
|
|
</h1>
|
|
|
|
{/* ── Two-tab strip (D-10) ──────────────────────────────────────────── */}
|
|
<div
|
|
role="tablist"
|
|
style={{
|
|
display: 'flex',
|
|
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
|
|
marginBottom: 'var(--space-6, 24px)',
|
|
}}
|
|
>
|
|
{(['members', 'settings'] as const).map((id) => (
|
|
<button
|
|
key={id}
|
|
role="tab"
|
|
id={`admin-tab-${id}`}
|
|
aria-selected={activeTab === id}
|
|
aria-controls={`admin-panel-${id}`}
|
|
tabIndex={activeTab === id ? 0 : -1}
|
|
onClick={() => setActiveTab(id)}
|
|
onKeyDown={(e) => handleTabKeyDown(e, id)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
|
minHeight: '44px',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: activeTab === id ? 600 : 400,
|
|
color:
|
|
activeTab === id ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
|
borderBottom:
|
|
activeTab === id ? '2px solid var(--color-member-0)' : '2px solid transparent',
|
|
transition: 'color 0.1s ease, border-color 0.1s ease',
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
>
|
|
{id === 'members' ? 'Members & Accounts' : 'Settings'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Tab panel: Members & Accounts ────────────────────────────────── */}
|
|
<div
|
|
role="tabpanel"
|
|
id="admin-panel-members"
|
|
aria-labelledby="admin-tab-members"
|
|
tabIndex={0}
|
|
hidden={activeTab !== 'members'}
|
|
>
|
|
{/* ── MEMBERS section ───────────────────────────────────────────── */}
|
|
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
|
<div style={sectionLabelStyle}>Members</div>
|
|
|
|
{membersQuery.isLoading && (
|
|
<div
|
|
style={{
|
|
padding: 'var(--space-4, 16px) 0',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
}}
|
|
>
|
|
Loading members…
|
|
</div>
|
|
)}
|
|
|
|
{membersQuery.isError && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-destructive)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
padding: 'var(--space-4, 16px) 0',
|
|
}}
|
|
>
|
|
Could not load members.
|
|
</div>
|
|
)}
|
|
|
|
{membersQuery.data && (
|
|
<div>
|
|
{membersQuery.data.members.map((member, idx) => (
|
|
<MemberRow
|
|
key={member.id}
|
|
member={member}
|
|
colorIndex={idx}
|
|
onAction={(buttonRef) => openSheet(member, buttonRef)}
|
|
onResetPassword={(buttonRef) => {
|
|
// Capture trigger button so focus can return on close
|
|
resetTriggerRef.current = buttonRef.current;
|
|
setResetTargetMember(member);
|
|
setResetSheetOpen(true);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
|
|
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
|
<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
|
|
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>
|
|
</section>
|
|
</div>
|
|
{/* end admin-panel-members */}
|
|
|
|
{/* ── Tab panel: Settings ───────────────────────────────────────────── */}
|
|
<div
|
|
role="tabpanel"
|
|
id="admin-panel-settings"
|
|
aria-labelledby="admin-tab-settings"
|
|
tabIndex={0}
|
|
hidden={activeTab !== 'settings'}
|
|
>
|
|
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
|
<section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
|
<div style={sectionLabelStyle}>Shared Calendar</div>
|
|
|
|
<p
|
|
style={{
|
|
margin: '0 0 var(--space-4, 16px) 0',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontWeight: 400,
|
|
color: 'var(--color-text-secondary)',
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
The shared family calendar is visible to all members in the same color lane.
|
|
</p>
|
|
|
|
{calendarsQuery.isLoading && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
}}
|
|
>
|
|
Loading calendars…
|
|
</div>
|
|
)}
|
|
|
|
{calendarsQuery.isError && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-destructive)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
}}
|
|
>
|
|
Could not load calendars.
|
|
</div>
|
|
)}
|
|
|
|
{calendarsQuery.data && calendarsQuery.data.calendars.length === 0 && (
|
|
<EmptyCalendarsState />
|
|
)}
|
|
|
|
{calendarsQuery.data && calendarsQuery.data.calendars.length > 0 && (
|
|
<>
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Select shared calendar"
|
|
style={{ marginBottom: 'var(--space-4, 16px)' }}
|
|
>
|
|
{calendarsQuery.data.calendars.map((cal) => (
|
|
<CalendarRadioRow
|
|
key={cal.id}
|
|
calendar={cal}
|
|
isSelected={effectiveSelected === cal.id}
|
|
onSelect={() => setSelectedCalendarId(cal.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Two-tap Save button */}
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<button
|
|
type="button"
|
|
disabled={saveDisabled}
|
|
onClick={() => {
|
|
if (effectiveSelected !== null) {
|
|
sharedCalMutation.mutate(effectiveSelected);
|
|
}
|
|
}}
|
|
style={{
|
|
background: saveDisabled
|
|
? 'var(--color-border, #E2E4E9)'
|
|
: 'var(--color-member-0, #4A90D9)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: saveDisabled ? 'default' : 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
minHeight: '44px',
|
|
minWidth: '44px',
|
|
padding: '0 var(--space-6, 24px)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
transition: 'background 0.15s ease',
|
|
}}
|
|
>
|
|
{sharedCalMutation.isPending ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
|
|
{sharedCalMutation.isError && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-destructive)',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
marginTop: 'var(--space-2, 8px)',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
Something went wrong. Please try again.
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── TIMEZONE section ─────────────────────────────────────────────── */}
|
|
<section aria-label="Timezone">
|
|
<div style={sectionLabelStyle}>Timezone</div>
|
|
|
|
{timezoneQuery.isLoading && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
}}
|
|
>
|
|
Loading timezone…
|
|
</div>
|
|
)}
|
|
|
|
{timezoneQuery.isError && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-destructive)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
}}
|
|
>
|
|
Could not load timezone setting.
|
|
</div>
|
|
)}
|
|
|
|
{timezoneQuery.data && (
|
|
<>
|
|
{/* System-default notice (D-06) */}
|
|
{!timezoneQuery.data.isExplicitlySet && (
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-muted)',
|
|
marginBottom: 'var(--space-3, 12px)',
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
Using system default — save a timezone to make it explicit.
|
|
</div>
|
|
)}
|
|
|
|
{/* IANA picker — searchable combobox. Focusing shows the full list
|
|
(no typing/erasing needed); typing filters it case-insensitively
|
|
(underscores ignored, so "new york" matches America/New_York). */}
|
|
<div style={{ position: 'relative', marginBottom: 'var(--space-3, 12px)' }}>
|
|
<input
|
|
type="text"
|
|
role="combobox"
|
|
aria-label="Household timezone"
|
|
aria-expanded={tzOpen}
|
|
aria-controls="tz-listbox"
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={
|
|
tzOpen && filteredZones.length ? `tz-opt-${tzActiveIndexClamped}` : undefined
|
|
}
|
|
autoComplete="off"
|
|
value={tzOpen ? (tzSearch ?? '') : effectiveTimezoneInput}
|
|
placeholder={
|
|
tzOpen ? effectiveTimezoneInput || 'Search timezones…' : 'Search timezones…'
|
|
}
|
|
onFocus={() => {
|
|
if (tzBlurTimer.current) clearTimeout(tzBlurTimer.current);
|
|
setTzOpen(true);
|
|
setTzSearch('');
|
|
setTzActiveIndex(0);
|
|
}}
|
|
onChange={(e) => {
|
|
setTzSearch(e.target.value);
|
|
setTzOpen(true);
|
|
setTzActiveIndex(0);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (!tzOpen) {
|
|
setTzOpen(true);
|
|
setTzSearch('');
|
|
}
|
|
// WR-06: clamp first, then advance — never start from a
|
|
// stale index past the end of the current filtered list.
|
|
setTzActiveIndex((i) =>
|
|
Math.min(
|
|
Math.min(i, Math.max(0, filteredZones.length - 1)) + 1,
|
|
filteredZones.length - 1,
|
|
),
|
|
);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setTzActiveIndex((i) =>
|
|
Math.max(Math.min(i, Math.max(0, filteredZones.length - 1)) - 1, 0),
|
|
);
|
|
} else if (e.key === 'Enter') {
|
|
// WR-06: commit the CLAMPED active option so Enter selects
|
|
// the same row the user sees highlighted / AT announces.
|
|
if (tzOpen && filteredZones[tzActiveIndexClamped]) {
|
|
e.preventDefault();
|
|
selectTimezone(filteredZones[tzActiveIndexClamped]);
|
|
}
|
|
} else if (e.key === 'Tab') {
|
|
// WR-07: commit the highlighted option on Tab WITHOUT
|
|
// preventDefault, so focus still advances to Save and the
|
|
// admin can't tab away leaving raw search text uncommitted.
|
|
if (tzOpen && filteredZones[tzActiveIndexClamped]) {
|
|
selectTimezone(filteredZones[tzActiveIndexClamped]);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
setTzOpen(false);
|
|
setTzSearch(null);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
// WR-07: options call preventDefault() on onMouseDown, so a
|
|
// click never blurs the input first — but a real focus change
|
|
// (Tab handled above, or clicking elsewhere) still needs to
|
|
// close the listbox. Defer one tick so any in-flight option
|
|
// mousedown settles before we close.
|
|
tzBlurTimer.current = setTimeout(() => {
|
|
setTzOpen(false);
|
|
setTzSearch(null);
|
|
}, 0);
|
|
}}
|
|
style={{
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
color: 'var(--color-text-primary)',
|
|
background: 'var(--color-surface, #ffffff)',
|
|
minHeight: '44px',
|
|
}}
|
|
/>
|
|
{tzOpen && (
|
|
<ul
|
|
id="tz-listbox"
|
|
role="listbox"
|
|
aria-label="Timezones"
|
|
style={{
|
|
position: 'absolute',
|
|
zIndex: 10,
|
|
top: 'calc(100% + 4px)',
|
|
left: 0,
|
|
right: 0,
|
|
margin: 0,
|
|
padding: 'var(--space-1, 4px)',
|
|
listStyle: 'none',
|
|
maxHeight: '260px',
|
|
overflowY: 'auto',
|
|
background: 'var(--color-surface, #ffffff)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
boxShadow: '0 6px 20px rgba(0,0,0,0.12)',
|
|
}}
|
|
>
|
|
{filteredZones.length === 0 && (
|
|
<li
|
|
style={{
|
|
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
|
|
color: 'var(--color-text-muted)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
}}
|
|
>
|
|
No matching timezones
|
|
</li>
|
|
)}
|
|
{filteredZones.map((tz, i) => {
|
|
// WR-06: highlight the clamped active row so the visual
|
|
// highlight matches aria-activedescendant exactly.
|
|
const active = i === tzActiveIndexClamped;
|
|
return (
|
|
<li
|
|
key={tz}
|
|
id={`tz-opt-${i}`}
|
|
role="option"
|
|
aria-selected={tz === effectiveTimezoneInput}
|
|
ref={
|
|
active
|
|
? (el) => {
|
|
el?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
: undefined
|
|
}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onMouseEnter={() => setTzActiveIndex(i)}
|
|
onClick={() => selectTimezone(tz)}
|
|
style={{
|
|
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
color: 'var(--color-text-primary)',
|
|
borderRadius: 'var(--space-1, 4px)',
|
|
cursor: 'pointer',
|
|
background: active ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
|
...(active ? { color: '#ffffff' } : null),
|
|
minHeight: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{tz}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Use detected zone affordance (D-02) */}
|
|
{detectedTz && detectedTz !== effectiveTimezoneInput && (
|
|
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimezoneInput(detectedTz)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-member-0, #4A90D9)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
textDecoration: 'underline',
|
|
}}
|
|
>
|
|
Use detected: {detectedTz}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Save button */}
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<button
|
|
type="button"
|
|
disabled={timezoneSaveDisabled}
|
|
onClick={() => {
|
|
if (effectiveTimezoneInput) {
|
|
timezoneMutation.mutate(effectiveTimezoneInput);
|
|
}
|
|
}}
|
|
style={{
|
|
background: timezoneSaveDisabled
|
|
? 'var(--color-border, #E2E4E9)'
|
|
: 'var(--color-member-0, #4A90D9)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: timezoneSaveDisabled ? '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',
|
|
}}
|
|
>
|
|
{timezoneMutation.isPending ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
|
|
{timezoneMutation.isError && (
|
|
<div
|
|
style={{
|
|
color: 'var(--color-destructive)',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
marginTop: 'var(--space-2, 8px)',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
Could not save timezone. Please check the value and try again.
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
{/* end admin-panel-settings */}
|
|
</div>
|
|
{/* end centered content column */}
|
|
|
|
{/* ── Success toast (D-08) ──────────────────────────────────────────────── */}
|
|
{toast && (
|
|
// WR-03: key on toast.id so an identical repeated message remounts and
|
|
// aria-live re-announces it (and the dismiss timer restarts).
|
|
<div
|
|
key={toast.id}
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: phone
|
|
? 'calc(var(--bottom-chrome-h) + var(--space-4, 16px))'
|
|
: 'var(--space-6, 24px)',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 400,
|
|
background: 'var(--color-surface-raised, #ffffff)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--space-2, 8px)',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-2, 8px)',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 'var(--text-label-weight, 400)' as React.CSSProperties['fontWeight'],
|
|
lineHeight: 'var(--text-label-line-height, 1.4)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
color: 'var(--color-text-primary)',
|
|
// WR-04: let the toast wrap instead of overflowing 90vw (nowrap + maxWidth
|
|
// overflows and trips the layout suite's no-horizontal-overflow rule).
|
|
maxWidth: '90vw',
|
|
}}
|
|
>
|
|
<CheckCircle
|
|
size={16}
|
|
aria-hidden="true"
|
|
style={{ color: 'var(--color-member-0)', flexShrink: 0 }}
|
|
/>
|
|
<span>{toast.msg}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Credential sheet — admin-rotate or admin-add */}
|
|
{sheetMember && (
|
|
<CredentialSheet
|
|
isOpen={sheetOpen}
|
|
onClose={() => setSheetOpen(false)}
|
|
mode={sheetMode}
|
|
memberName={sheetMember.displayName}
|
|
memberId={sheetMember.id}
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── MemberRow ──────────────────────────────────────────────────────────────
|
|
|
|
interface MemberRowProps {
|
|
member: AdminMember;
|
|
colorIndex: number;
|
|
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
|
onResetPassword?: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
|
}
|
|
|
|
function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) {
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const resetBtnRef = useRef<HTMLButtonElement>(null);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3, 12px)',
|
|
minHeight: '44px',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
|
|
}}
|
|
>
|
|
{/* Avatar swatch */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
width: '32px',
|
|
height: '32px',
|
|
borderRadius: '50%',
|
|
background: `var(--color-member-${colorIndex}, var(--color-member-0))`,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
|
|
{/* Name + status */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontWeight: 400,
|
|
color: 'var(--color-text-primary)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{member.displayName ?? 'Member'}
|
|
</div>
|
|
|
|
{/* Credential status badge */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-1, 4px)',
|
|
marginTop: '2px',
|
|
}}
|
|
>
|
|
{member.hasCredential ? (
|
|
<>
|
|
<CheckCircle
|
|
size={16}
|
|
aria-hidden="true"
|
|
style={{ color: 'var(--color-text-secondary)', flexShrink: 0 }}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-secondary)',
|
|
}}
|
|
>
|
|
Credential set
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AlertCircle
|
|
size={16}
|
|
aria-hidden="true"
|
|
style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
No credential
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action button row */}
|
|
<div style={{ display: 'flex', gap: 'var(--space-2, 8px)', flexShrink: 0 }}>
|
|
{/* Credential rotate/add button */}
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={() => onAction(buttonRef)}
|
|
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)',
|
|
}}
|
|
>
|
|
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
|
</button>
|
|
|
|
{/* 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── CalendarRadioRow ────────────────────────────────────────────────────────
|
|
|
|
interface CalendarRadioRowProps {
|
|
calendar: AdminCalendar;
|
|
isSelected: boolean;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowProps) {
|
|
return (
|
|
<div
|
|
role="radio"
|
|
aria-checked={isSelected}
|
|
tabIndex={0}
|
|
onClick={onSelect}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3, 12px)',
|
|
minHeight: '44px',
|
|
padding: 'var(--space-2, 8px) 0',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{/* Radio indicator: 20px circle */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
width: '20px',
|
|
height: '20px',
|
|
borderRadius: '50%',
|
|
flexShrink: 0,
|
|
border: isSelected ? 'none' : '2px solid var(--color-border)',
|
|
background: isSelected ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{isSelected && (
|
|
<div
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
background: '#ffffff',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Calendar name */}
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-body-size, 15px)',
|
|
fontWeight: 400,
|
|
color: 'var(--color-text-primary)',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{calendar.displayName}
|
|
</span>
|
|
|
|
{/* Currently shared label */}
|
|
{calendar.isShared && (
|
|
<span
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 400,
|
|
color: 'var(--color-member-0, #4A90D9)',
|
|
}}
|
|
>
|
|
Currently shared
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 ─────────────────────────────────────────────────────
|
|
|
|
function EmptyCalendarsState() {
|
|
return (
|
|
<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 calendars synced yet
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-secondary)',
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
Calendars sync automatically. Check back after the first sync completes.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|