From 7808426a2f0d7ff7116b82ec5613a40f63269368 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 15:16:11 -0400 Subject: [PATCH] feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/pwa/e2e/admin.spec.ts | 109 +++++ apps/pwa/src/App.tsx | 29 +- apps/pwa/src/components/AppNav.tsx | 15 +- apps/pwa/src/components/BottomTabBar.tsx | 24 +- apps/pwa/src/routes/AdminPage.tsx | 544 +++++++++++++++++++++++ 5 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 apps/pwa/e2e/admin.spec.ts create mode 100644 apps/pwa/src/routes/AdminPage.tsx diff --git a/apps/pwa/e2e/admin.spec.ts b/apps/pwa/e2e/admin.spec.ts new file mode 100644 index 0000000..3384a0b --- /dev/null +++ b/apps/pwa/e2e/admin.spec.ts @@ -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
) + 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); + }); +}); diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 51fd2a6..1b3d39f 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -42,10 +42,12 @@ import { useQuery } from '@tanstack/react-query'; import { CalendarShell } from './components/CalendarShell.js'; import { ListsIndex } from './routes/ListsIndex.js'; import { ListDetail } from './routes/ListDetail.js'; +import { AdminPage } from './routes/AdminPage.js'; import { BottomTabBar } from './components/BottomTabBar.js'; import { AppNav } from './components/AppNav.js'; import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'; import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'; +import { SetupBanner } from './components/SetupBanner.js'; import { SettingsSheet } from './components/SettingsSheet.js'; import { fetchMe } from './api/client.js'; @@ -67,6 +69,11 @@ export default function App() { 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 const members = useMemo(() => { if (!meQuery.data?.user) return []; @@ -114,21 +121,41 @@ export default function App() { currentUserColor={meQuery.data?.user.color} currentUserName={meQuery.data?.user.displayName ?? undefined} onOpenSettings={() => setSettingsOpen(true)} + isAdmin={isAdmin} /> {/* Main content area — all routes render here */}
+ {/* SetupBanner: shown above content when needsProviderSetup=true (D-07). + Reads from the shared ['me'] query — no additional fetch. */} + + } /> } /> } /> } /> + {/* /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. */} +
{/* BottomTabBar — phone-only (hidden ≥768px via CSS, FIX 4) */} - + {/* Post-install permission prompt (D-08): renders only when isInstalled() is true and Notification.permission === 'default' and not dismissed */} diff --git a/apps/pwa/src/components/AppNav.tsx b/apps/pwa/src/components/AppNav.tsx index 31fa66f..9217eb7 100644 --- a/apps/pwa/src/components/AppNav.tsx +++ b/apps/pwa/src/components/AppNav.tsx @@ -12,7 +12,7 @@ */ 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'; interface AppNavProps { @@ -21,6 +21,8 @@ interface AppNavProps { currentUserName?: string; /** Called when the user avatar is tapped — opens the Settings sheet. */ onOpenSettings?: () => void; + /** When true, renders the Admin nav entry (ShieldCheck). UX gating only (D-03). */ + isAdmin?: boolean; } export function AppNav({ @@ -28,6 +30,7 @@ export function AppNav({ currentUserColor, currentUserName, onOpenSettings, + isAdmin = false, }: AppNavProps) { const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; @@ -47,6 +50,7 @@ export function AppNav({ currentUserColor={currentUserColor} currentUserName={currentUserName} onOpenSettings={onOpenSettings} + isAdmin={isAdmin} /> ); } @@ -129,11 +133,13 @@ function DesktopNav({ currentUserColor, currentUserName, onOpenSettings, + isAdmin = false, }: { members: LegendMember[]; currentUserColor?: string; currentUserName?: string; onOpenSettings?: () => void; + isAdmin?: boolean; }) { const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({ display: 'flex', @@ -198,6 +204,13 @@ function DesktopNav({