Files
familysync/apps/pwa/src/App.tsx
T
Lucas BergerandClaude Opus 4.8 b6490feff4
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
fix(19): satisfy CI fast-checks + secret scan
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
  delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
  dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
  (localSession.test), makeUnauthContext + BrowserContext import (login.spec),
  unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
  eslint-disable directives

Format: prettier --write across the 20 Phase-19 files that were never formatted.

Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.

Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.

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

284 lines
13 KiB
TypeScript

/**
* App — BrowserRouter shell with react-router declarative routing.
*
* Routes:
* / → redirect to /calendar
* /calendar → CalendarShell
* /lists → ListsIndex
* /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),
* so it survives route transitions (FIX 3). AppNav provides:
* - Phone (≤767px): 48px top bar (the header above the main content area)
* - Desktop (≥768px): 240px left sidebar with nav links + colour legend
*
* BottomTabBar is also a sibling of <Routes> so the tab state remains consistent.
* On desktop (≥768px) BottomTabBar is hidden via CSS (FIX 4).
*
* Auth flow:
* /api/me is fetched once at the App level. While loading or on error the
* full-screen AuthSplash overlay is shown (CalendarShell's AuthSplash is
* positioned fixed with z-index so it covers AppNav too). AppNav renders
* with empty / partial data until auth resolves — this is fine because the
* AuthSplash overlay hides the AppNav during that window.
*
* navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to
* /lists/* — the SW denylist only excludes /callback, /api/, and /health, so
* /lists/* is served from cache correctly.
*
* Phase 5 additions:
* - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10)
* - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09)
*
* Phase 6 fix (FIX 3):
* - AppNav lifted from CalendarShell to this level so /lists keeps the nav chrome.
* - /api/me query shared so AppNav has user data on all routes.
*/
import { useState, useMemo } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
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 { SetupPage } from './routes/SetupPage.js';
import { LoginPage } from './routes/LoginPage.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, fetchSetupStatus, fetchAuthMode } from './api/client.js';
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}
/**
* OidcRedirect — tiny helper that triggers a top-level navigation to /api/login.
*
* Used in the auth gate when localEnabled === false and oidcEnabled === true —
* the OIDC-only mode that was the app's only auth path before Phase 19.
* A top-level navigation (not a React Router navigate) is required because
* /api/login responds with a 302 redirect to the external OIDC provider,
* which browsers cannot follow as a fetch/XHR (T-07-04).
*/
function OidcRedirect() {
window.location.replace('/api/login');
return <div aria-hidden="true" />;
}
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.
//
// gap 6 (mechanism (ii) — ['me'] staleness, NOT a linking gap; see SUMMARY):
// the first-login claim in upsertUser (auth/user.ts) preserves the same users.id,
// so the wizard-stored CalDAV credential stays linked → needsProviderSetup is
// correctly FALSE in the DB after the operator authenticates post-wizard. The bug
// was purely client-cache: a ['me'] entry populated BEFORE the claim (e.g. an
// earlier pre-auth visit) served a stale needsProviderSetup=true for up to 5
// minutes, so the "Set up your calendar" banner kept showing. Set staleTime 0 on
// the boot ['me'] query so the authenticated app shell always refetches member
// status on entry — needsProviderSetup then reflects the just-claimed credential.
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
staleTime: 0,
});
// Auth mode query — fetched pre-auth (no session required).
// Determines whether to show /login (localEnabled) or OIDC redirect (!localEnabled && oidcEnabled).
// staleTime 60s: auth mode changes rarely; re-fetches on new tab/focus.
const authModeQuery = useQuery({
queryKey: ['authMode'],
queryFn: fetchAuthMode,
retry: false,
staleTime: 60_000,
});
// 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 [];
return [
{
id: String(meQuery.data.user.id),
name: meQuery.data.user.displayName ?? 'Member',
color: meQuery.data.user.color,
},
];
}, [meQuery.data]);
// Outer layout: phone = column, desktop = row (AppNav sidebar + content)
const outerStyle: React.CSSProperties = {
height: '100dvh',
display: 'flex',
flexDirection: phone ? 'column' : 'row',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
overflow: 'hidden',
};
// Content area: fills remaining space next to / below AppNav
const contentStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
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>
<Routes>
{/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing).
Reverse gate (gap 5, T-12-04): once setup is complete the wizard must NOT re-mount.
- While setupQuery is loading → render the no-flash placeholder (no wizard before
status resolves), mirroring the `*`-route loading gate below.
- setupComplete === true → render SetupPage with alreadyLocked → Surface 8
("Setup already complete"), keeping the operator on /setup with a terminal surface.
- setupComplete === false (or undefined post-load) → active wizard, as before.
The backend already 423s setup mutations; this is the matching frontend reverse-gate. */}
<Route
path="/setup"
element={
setupLoading ? (
<div aria-hidden="true" />
) : setupComplete === true ? (
<SetupPage alreadyLocked={true} />
) : (
<SetupPage />
)
}
/>
{/* /login route — standalone login page, no AppNav/BottomTabBar shell (UI-SPEC §Surface 1).
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
<Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
{/* 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 />
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
// Unauthenticated + localEnabled: redirect to /login
<Navigate to="/login" replace />
) : meQuery.isError &&
!meQuery.isLoading &&
!authModeQuery.data?.localEnabled &&
authModeQuery.data?.oidcEnabled ? (
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
// Use a render side-effect via useEffect isn't available here; use a helper element
<OidcRedirect />
) : (
// 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)} />
</>
)
}
/>
</Routes>
</BrowserRouter>
);
}