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:
+28
-1
@@ -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 */}
|
||||
<div style={contentStyle}>
|
||||
{/* SetupBanner: shown above content when needsProviderSetup=true (D-07).
|
||||
Reads from the shared ['me'] query — no additional fetch. */}
|
||||
<SetupBanner />
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||
<Route path="/calendar" element={<CalendarShell />} />
|
||||
<Route path="/lists" element={<ListsIndex />} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
and Notification.permission === 'default' and not dismissed */}
|
||||
|
||||
Reference in New Issue
Block a user