feat(12-04): App.tsx setup-status gate + /setup route + redirect
- Add setupQuery (queryKey: setupStatus, staleTime: 0) alongside meQuery;
queries GET /api/setup/status via fetchSetupStatus on every app load
- Add <Route path="/setup" element={<SetupPage />}> as standalone pre-auth route
- Add redirect gate: while loading → aria-hidden div (no flash); setupComplete===false
→ <Navigate to="/setup"> (no AppNav/BottomTabBar rendered); true → normal shell
- Add App.test.tsx covering both branches (setupComplete false/true) + loading state;
236 tests pass, typecheck clean
This commit is contained in:
+99
-53
@@ -5,7 +5,15 @@
|
||||
* / → redirect to /calendar
|
||||
* /calendar → CalendarShell
|
||||
* /lists → ListsIndex
|
||||
* /lists/:listId → ListDetail (placeholder for Plan 04-04)
|
||||
* /lists/:listId → ListDetail
|
||||
* /setup → SetupPage (standalone wizard — no AppNav/BottomTabBar)
|
||||
*
|
||||
* Setup gate (Phase 12):
|
||||
* On app load, GET /api/setup/status is fetched with staleTime 0 (always fresh).
|
||||
* While loading: render nothing (prevent flash — mirrors the isAdmin loading gate).
|
||||
* When setupComplete === false: redirect all non-/setup routes to /setup via Navigate.
|
||||
* When setupComplete === true: normal app boot proceeds.
|
||||
* The /setup route renders standalone — AppNav/BottomTabBar are NOT rendered on wizard.
|
||||
*
|
||||
* Layout:
|
||||
* AppNav is rendered as a PERSISTENT sibling of <Routes> (outside any Route),
|
||||
@@ -43,13 +51,14 @@ 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 { SetupPage } from './routes/SetupPage.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';
|
||||
import { fetchMe, fetchSetupStatus } from './api/client.js';
|
||||
|
||||
function isPhone(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
@@ -59,6 +68,16 @@ export default function App() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const phone = isPhone();
|
||||
|
||||
// Setup status query — staleTime 0 so the wizard gate is always fresh (D-10 spirit).
|
||||
// Must be fetched before rendering any authenticated route to gate the app on /setup.
|
||||
// Uses the pre-auth /api/setup/status endpoint — no OIDC session required.
|
||||
const setupQuery = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: fetchSetupStatus,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Fetch current user once at the app shell level so AppNav has member data on
|
||||
// ALL routes. This is the same query key (['me']) used by CalendarShell, so
|
||||
// TanStack Query deduplicates the request — no double fetch.
|
||||
@@ -108,61 +127,88 @@ export default function App() {
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
// Setup gate: while setup status is loading, render nothing (prevent flash).
|
||||
// Mirrors the isAdmin loading-gate pattern for the admin route.
|
||||
const setupComplete = setupQuery.data?.setupComplete;
|
||||
const setupLoading = setupQuery.isLoading;
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
|
||||
<PermissionDeniedBanner />
|
||||
<Routes>
|
||||
{/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing) */}
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
|
||||
<div style={outerStyle}>
|
||||
{/* Persistent AppNav — phone: top bar; desktop: left sidebar.
|
||||
Renders on ALL routes so nav chrome survives route transitions (FIX 3). */}
|
||||
<AppNav
|
||||
members={members}
|
||||
currentUserColor={meQuery.data?.user.color}
|
||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
isAdmin={isAdmin}
|
||||
{/* All other routes are gated on setup completion */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
// While setupQuery is loading: render nothing (no flash before redirect)
|
||||
setupLoading ? (
|
||||
<div aria-hidden="true" />
|
||||
) : setupComplete === false ? (
|
||||
// Not configured: full-app redirect to /setup (no nav shell rendered)
|
||||
<Navigate to="/setup" replace />
|
||||
) : (
|
||||
// Setup complete: render the normal authenticated app shell
|
||||
<>
|
||||
{/* Permission-denied banner: shown when OS revoked and user had notifications on (D-10) */}
|
||||
<PermissionDeniedBanner />
|
||||
|
||||
<div style={outerStyle}>
|
||||
{/* Persistent AppNav — phone: top bar; desktop: left sidebar.
|
||||
Renders on ALL routes so nav chrome survives route transitions (FIX 3). */}
|
||||
<AppNav
|
||||
members={members}
|
||||
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 isAdmin={isAdmin} />
|
||||
|
||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||
and Notification.permission === 'default' and not dismissed */}
|
||||
<PushPermissionPrompt />
|
||||
|
||||
{/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
|
||||
<SettingsSheet isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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 isAdmin={isAdmin} />
|
||||
|
||||
{/* Post-install permission prompt (D-08): renders only when isInstalled() is true
|
||||
and Notification.permission === 'default' and not dismissed */}
|
||||
<PushPermissionPrompt />
|
||||
|
||||
{/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
|
||||
<SettingsSheet isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user