/** * 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, 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); // 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; // 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.
)} )}
{/* 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.
); }