/** * 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('admin-add'); const [sheetMember, setSheetMember] = useState(null); const triggerRef = useRef(null); // Reset-password sheet state (Surface 11B) const [resetSheetOpen, setResetSheetOpen] = useState(false); const [resetTargetMember, setResetTargetMember] = useState(null); // resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close const resetTriggerRef = useRef(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(null); // Shared calendar picker state const [selectedCalendarId, setSelectedCalendarId] = useState(null); // Timezone picker state const [timezoneInput, setTimezoneInput] = useState(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(null); const [tzActiveIndex, setTzActiveIndex] = useState(0); const tzBlurTimer = useRef | 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) { // Capture the button so focus can return on close (triggerRef as React.MutableRefObject).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 (
{/* Page heading */}

Admin Settings

{/* ── Two-tab strip (D-10) ──────────────────────────────────────────── */}
{(['members', 'settings'] as const).map((id) => ( ))}
{/* ── Tab panel: Members & Accounts ────────────────────────────────── */} {/* end admin-panel-members */} {/* ── Tab panel: Settings ───────────────────────────────────────────── */} {/* end admin-panel-settings */}
{/* 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).
)} {/* Credential sheet — admin-rotate or admin-add */} {sheetMember && ( setSheetOpen(false)} mode={sheetMode} memberName={sheetMember.displayName} memberId={sheetMember.id} triggerRef={triggerRef} /> )} {/* Surface 11B — Reset password sheet */} {resetTargetMember && ( { setResetSheetOpen(false); // Return focus to trigger if (resetTriggerRef.current) { resetTriggerRef.current.focus(); } }} onSuccess={() => showToast('Password reset.')} member={resetTargetMember} /> )}
); } // ── MemberRow ────────────────────────────────────────────────────────────── interface MemberRowProps { member: AdminMember; colorIndex: number; onAction: (buttonRef: React.RefObject) => void; onResetPassword?: (buttonRef: React.RefObject) => void; } function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) { const buttonRef = useRef(null); const resetBtnRef = useRef(null); return (
{/* Avatar swatch */} ); } // ── CalendarRadioRow ──────────────────────────────────────────────────────── interface CalendarRadioRowProps { calendar: AdminCalendar; isSelected: boolean; onSelect: () => void; } function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowProps) { return (
{ 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 */} ); } // ── 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(null); const headingRef = useRef(null); // WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true"). const dialogRef = useRef(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 */}