feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec
- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state - App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin - AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating) - BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating) - e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar - All 15 e2e tests pass (iphone/pixel/desktop); production build clean
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* admin.spec.ts — Phase 10 Plan 04 admin route gate
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry
|
||||||
|
* and reaches /admin with "Admin Settings" heading + Members section.
|
||||||
|
* - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is
|
||||||
|
* redirected from /admin to /calendar.
|
||||||
|
*
|
||||||
|
* Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md).
|
||||||
|
* global-setup seeds: users id=1 is_admin=true (Plan 10-01 note).
|
||||||
|
*
|
||||||
|
* Route-mock pattern for non-admin simulation:
|
||||||
|
* page.route('/api/me', ...) → { user: { ..., isAdmin: false, needsProviderSetup: false } }
|
||||||
|
* per [[dev-data-user1-no-calendars]] idiom + lists.spec page.route precedent.
|
||||||
|
*
|
||||||
|
* Runs on all three device profiles automatically (playwright.config.ts matrix):
|
||||||
|
* iphone: iPhone 14 / WebKit / 390×844
|
||||||
|
* pixel: Pixel 7 / Chromium / 412×915
|
||||||
|
* desktop: Desktop Chrome / Chromium / 1280×720
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* pnpm --filter @familysync/pwa test:e2e
|
||||||
|
* pnpm --filter @familysync/pwa exec playwright test admin.spec.ts
|
||||||
|
* pnpm --filter @familysync/pwa test:e2e -- admin
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// ── Admin user (seeded is_admin=true) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Admin user — admin nav entry + /admin route', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/calendar');
|
||||||
|
// Wait for auth and nav to be visible before asserting
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin sees the Admin nav entry (ShieldCheck, aria-label="Admin settings")', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// The admin nav link is rendered with aria-label="Admin settings" in both
|
||||||
|
// AppNav (desktop) and BottomTabBar (mobile).
|
||||||
|
const adminEntry = page.getByRole('link', { name: 'Admin settings' });
|
||||||
|
await expect(adminEntry).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin reaches /admin and sees "Admin Settings" heading', async ({ page }) => {
|
||||||
|
// Navigate directly — also verifies the route guard does NOT redirect admins
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||||
|
// The page heading is "Admin Settings"
|
||||||
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin /admin page renders the MEMBERS section', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||||
|
// The section is labeled "Members" (aria-label on <section>)
|
||||||
|
await expect(page.getByRole('region', { name: 'Members' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Non-admin user (route-mocked isAdmin:false) ───────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Non-admin user — admin nav entry hidden + /admin redirect', () => {
|
||||||
|
// Route-mock /api/me to return isAdmin:false BEFORE navigation so the PWA
|
||||||
|
// never sees isAdmin:true in this test context.
|
||||||
|
const mockNonAdminMe = async (page: import('@playwright/test').Page) => {
|
||||||
|
await page.route('/api/me', (route) => {
|
||||||
|
void route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
displayName: 'Dev User',
|
||||||
|
color: '#4A90D9',
|
||||||
|
isAdmin: false,
|
||||||
|
needsProviderSetup: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('non-admin does NOT see the Admin nav entry', async ({ page }) => {
|
||||||
|
await mockNonAdminMe(page);
|
||||||
|
await page.goto('/calendar');
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
|
||||||
|
// Admin entry must be absent
|
||||||
|
const adminEntry = page.getByRole('link', { name: 'Admin settings' });
|
||||||
|
await expect(adminEntry).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-admin navigating to /admin is redirected to /calendar', async ({ page }) => {
|
||||||
|
await mockNonAdminMe(page);
|
||||||
|
await page.goto('/admin');
|
||||||
|
// Wait for meQuery to resolve and redirect to fire — the Navigate component
|
||||||
|
// replaces the URL once meQuery.isLoading = false + isAdmin = false.
|
||||||
|
await page.waitForURL(/\/calendar/, { timeout: 10_000 });
|
||||||
|
// Should have landed on /calendar
|
||||||
|
const url = new URL(page.url());
|
||||||
|
expect(url.pathname, `Expected /calendar but got ${url.pathname}`).toMatch(
|
||||||
|
/^\/(calendar)?$/,
|
||||||
|
);
|
||||||
|
// "Admin Settings" heading must NOT be present
|
||||||
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
-1
@@ -42,10 +42,12 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { CalendarShell } from './components/CalendarShell.js';
|
import { CalendarShell } from './components/CalendarShell.js';
|
||||||
import { ListsIndex } from './routes/ListsIndex.js';
|
import { ListsIndex } from './routes/ListsIndex.js';
|
||||||
import { ListDetail } from './routes/ListDetail.js';
|
import { ListDetail } from './routes/ListDetail.js';
|
||||||
|
import { AdminPage } from './routes/AdminPage.js';
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||||
import { AppNav } from './components/AppNav.js';
|
import { AppNav } from './components/AppNav.js';
|
||||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||||
|
import { SetupBanner } from './components/SetupBanner.js';
|
||||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||||
import { fetchMe } from './api/client.js';
|
import { fetchMe } from './api/client.js';
|
||||||
|
|
||||||
@@ -67,6 +69,11 @@ export default function App() {
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
|
||||||
|
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
|
||||||
|
// (loading gate: no flash of admin content for non-admins).
|
||||||
|
const isAdmin = meQuery.data?.user.isAdmin ?? false;
|
||||||
|
|
||||||
// Derive members for AppNav from the shared /api/me response
|
// Derive members for AppNav from the shared /api/me response
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (!meQuery.data?.user) return [];
|
if (!meQuery.data?.user) return [];
|
||||||
@@ -114,21 +121,41 @@ export default function App() {
|
|||||||
currentUserColor={meQuery.data?.user.color}
|
currentUserColor={meQuery.data?.user.color}
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area — all routes render here */}
|
{/* Main content area — all routes render here */}
|
||||||
<div style={contentStyle}>
|
<div style={contentStyle}>
|
||||||
|
{/* SetupBanner: shown above content when needsProviderSetup=true (D-07).
|
||||||
|
Reads from the shared ['me'] query — no additional fetch. */}
|
||||||
|
<SetupBanner />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||||
<Route path="/calendar" element={<CalendarShell />} />
|
<Route path="/calendar" element={<CalendarShell />} />
|
||||||
<Route path="/lists" element={<ListsIndex />} />
|
<Route path="/lists" element={<ListsIndex />} />
|
||||||
<Route path="/lists/:listId" element={<ListDetail />} />
|
<Route path="/lists/:listId" element={<ListDetail />} />
|
||||||
|
{/* /admin route: gated by isAdmin (UX, D-03). Server enforces 403 on all /api/admin/* */}
|
||||||
|
{/* Loading gate: show nothing while meQuery is fetching (prevents flash).
|
||||||
|
Once resolved: isAdmin → AdminPage; else → redirect to /calendar. */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
meQuery.isLoading ? (
|
||||||
|
<div aria-hidden="true" />
|
||||||
|
) : isAdmin ? (
|
||||||
|
<AdminPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/calendar" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */}
|
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */}
|
||||||
<BottomTabBar />
|
<BottomTabBar isAdmin={isAdmin} />
|
||||||
|
|
||||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||||
and Notification.permission === 'default' and not dismissed */}
|
and Notification.permission === 'default' and not dismissed */}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { CalendarDays, List } from 'lucide-react';
|
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
|
||||||
import { ColorLegend, type LegendMember } from './ColorLegend.js';
|
import { ColorLegend, type LegendMember } from './ColorLegend.js';
|
||||||
|
|
||||||
interface AppNavProps {
|
interface AppNavProps {
|
||||||
@@ -21,6 +21,8 @@ interface AppNavProps {
|
|||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
/** Called when the user avatar is tapped — opens the Settings sheet. */
|
/** Called when the user avatar is tapped — opens the Settings sheet. */
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
|
/** When true, renders the Admin nav entry (ShieldCheck). UX gating only (D-03). */
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppNav({
|
export function AppNav({
|
||||||
@@ -28,6 +30,7 @@ export function AppNav({
|
|||||||
currentUserColor,
|
currentUserColor,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
isAdmin = false,
|
||||||
}: AppNavProps) {
|
}: AppNavProps) {
|
||||||
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ export function AppNav({
|
|||||||
currentUserColor={currentUserColor}
|
currentUserColor={currentUserColor}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,11 +133,13 @@ function DesktopNav({
|
|||||||
currentUserColor,
|
currentUserColor,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
isAdmin = false,
|
||||||
}: {
|
}: {
|
||||||
members: LegendMember[];
|
members: LegendMember[];
|
||||||
currentUserColor?: string;
|
currentUserColor?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
|
isAdmin?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -198,6 +204,13 @@ function DesktopNav({
|
|||||||
<List size={18} aria-hidden="true" />
|
<List size={18} aria-hidden="true" />
|
||||||
Lists
|
Lists
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{/* Admin entry — only when isAdmin=true (UX gating, D-03) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<NavLink to="/admin" style={navLinkStyle} aria-label="Admin settings">
|
||||||
|
<ShieldCheck size={18} aria-hidden="true" />
|
||||||
|
Admin
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color legend */}
|
{/* Color legend */}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { CalendarDays, List } from 'lucide-react';
|
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
function isPhone(): boolean {
|
function isPhone(): boolean {
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
@@ -49,7 +49,12 @@ const tabActiveOverride: React.CSSProperties = {
|
|||||||
borderBottom: '2px solid var(--color-member-0)',
|
borderBottom: '2px solid var(--color-member-0)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomTabBar() {
|
interface BottomTabBarProps {
|
||||||
|
/** When true, renders the Admin tab (ShieldCheck). UX gating only (D-03). */
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomTabBar({ isAdmin = false }: BottomTabBarProps) {
|
||||||
// Phone-only: return null on desktop (≥768px) so the fixed bar does not overlay
|
// Phone-only: return null on desktop (≥768px) so the fixed bar does not overlay
|
||||||
// the AppNav sidebar's Settings/avatar button (FIX 4). Consistent with the
|
// the AppNav sidebar's Settings/avatar button (FIX 4). Consistent with the
|
||||||
// isPhone() breakpoint used in AppNav and CalendarShell.
|
// isPhone() breakpoint used in AppNav and CalendarShell.
|
||||||
@@ -96,6 +101,21 @@ export function BottomTabBar() {
|
|||||||
<List size={22} aria-hidden="true" />
|
<List size={22} aria-hidden="true" />
|
||||||
<span>Lists</span>
|
<span>Lists</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* Admin tab — only when isAdmin=true (UX gating, D-03) */}
|
||||||
|
{isAdmin && (
|
||||||
|
<NavLink
|
||||||
|
to="/admin"
|
||||||
|
aria-label="Admin settings"
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
...tabBase,
|
||||||
|
...(isActive ? tabActiveOverride : {}),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={22} aria-hidden="true" />
|
||||||
|
<span>Admin</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
/**
|
||||||
|
* 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<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);
|
||||||
|
|
||||||
|
// 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<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">
|
||||||
|
<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>
|
||||||
|
</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 as React.RefObject<HTMLElement | null>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user