/** * 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 } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { CheckCircle, AlertCircle } from 'lucide-react'; import { fetchAdminMembers, fetchAdminCalendars, setSharedCalendar, fetchAdminTimezone, setAdminTimezone, type AdminMember, type AdminCalendar, } from '../api/client.js'; import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.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(); // Credential sheet state const [sheetOpen, setSheetOpen] = useState(false); const [sheetMode, setSheetMode] = useState('admin-add'); const [sheetMember, setSheetMember] = useState(null); const triggerRef = useRef(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); // 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) const detectedTz = 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; // 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 }, }); // 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); } const saveDisabled = sharedCalMutation.isPending || effectiveSelected === null || effectiveSelected === currentSharedId; return (
{/* Page heading */}

Admin Settings

{/* ── MEMBERS section ─────────────────────────────────────────────── */}
Members
{membersQuery.isLoading && (
Loading members…
)} {membersQuery.isError && (
Could not load members.
)} {membersQuery.data && (
{membersQuery.data.members.map((member, idx) => ( openSheet(member, buttonRef)} /> ))}
)}
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
Shared Calendar

The shared family calendar is visible to all members in the same color lane.

{calendarsQuery.isLoading && (
Loading calendars…
)} {calendarsQuery.isError && (
Could not load calendars.
)} {calendarsQuery.data && calendarsQuery.data.calendars.length === 0 && ( )} {calendarsQuery.data && calendarsQuery.data.calendars.length > 0 && ( <>
{calendarsQuery.data.calendars.map((cal) => ( setSelectedCalendarId(cal.id)} /> ))}
{/* Two-tap Save button */}
{sharedCalMutation.isError && (
Something went wrong. Please try again.
)} )}
{/* ── TIMEZONE section ─────────────────────────────────────────────── */}
Timezone
{timezoneQuery.isLoading && (
Loading timezone…
)} {timezoneQuery.isError && (
Could not load timezone setting.
)} {timezoneQuery.data && ( <> {/* System-default notice (D-06) */} {!timezoneQuery.data.isExplicitlySet && (
Using system default — save a timezone to make it explicit.
)} {/* 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). */}
{ 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(''); } setTzActiveIndex((i) => Math.min(i + 1, filteredZones.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setTzActiveIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { if (tzOpen && filteredZones[tzActiveIndex]) { e.preventDefault(); selectTimezone(filteredZones[tzActiveIndex]); } } else if (e.key === 'Escape') { setTzOpen(false); setTzSearch(null); } }} onBlur={() => { // Delay so an option's onClick fires before the list unmounts. tzBlurTimer.current = setTimeout(() => { setTzOpen(false); setTzSearch(null); }, 120); }} 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 && (
    {filteredZones.length === 0 && (
  • No matching timezones
  • )} {filteredZones.map((tz, i) => { const active = i === tzActiveIndex; return (
  • { 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}
  • ); })}
)}
{/* Use detected zone affordance (D-02) */} {detectedTz && detectedTz !== effectiveTimezoneInput && (
)} {/* Save button */}
{timezoneMutation.isError && (
Could not save timezone. Please check the value and try again.
)} )}
{/* Credential sheet — admin-rotate or admin-add */} {sheetMember && ( setSheetOpen(false)} mode={sheetMode} memberName={sheetMember.displayName} memberId={sheetMember.id} triggerRef={triggerRef} /> )}
); } // ── MemberRow ────────────────────────────────────────────────────────────── interface MemberRowProps { member: AdminMember; colorIndex: number; onAction: (buttonRef: React.RefObject) => void; } function MemberRow({ member, colorIndex, onAction }: MemberRowProps) { const buttonRef = 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 */} ); } // ── EmptyCalendarsState ───────────────────────────────────────────────────── function EmptyCalendarsState() { return (
No calendars synced yet
Calendars sync automatically. Check back after the first sync completes.
); }