Files
familysync/apps/pwa/src/routes/AdminPage.tsx
T
Lucas BergerandClaude Opus 4.8 d6f6a5ae6f fix(18): searchable timezone combobox with type-to-search
Replace the picker with an accessible combobox (role=combobox + role=listbox):
focusing shows the full zone list (no typing/erasing needed), typing filters it
case-insensitively (underscores ignored, so "york" matches America/New_York),
with arrow-key navigation, Enter/click to select, and Escape to close. Fixes the
datalist limitation where a pre-filled value collapsed the dropdown to one match.
e2e updated to type+click options and a type-to-search case added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:36:49 -04:00

877 lines
31 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 } 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<CredentialSheetMode>('admin-add');
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
const triggerRef = useRef<HTMLButtonElement>(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);
// 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<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);
}
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-8, 32px) 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>
{/* ── 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)}
/>
))}
</div>
)}
</section>
{/* ── 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-${tzActiveIndex}` : 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('');
}
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 && (
<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) => {
const active = i === tzActiveIndex;
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>
{/* Credential sheet — admin-rotate or admin-add */}
{sheetMember && (
<CredentialSheet
isOpen={sheetOpen}
onClose={() => setSheetOpen(false)}
mode={sheetMode}
memberName={sheetMember.displayName}
memberId={sheetMember.id}
triggerRef={triggerRef}
/>
)}
</div>
);
}
// ── MemberRow ──────────────────────────────────────────────────────────────
interface MemberRowProps {
member: AdminMember;
colorIndex: number;
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
}
function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
const buttonRef = 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 */}
<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)',
flexShrink: 0,
}}
>
{member.hasCredential ? 'Rotate' : 'Add credential'}
</button>
</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>
);
}
// ── 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>
);
}