Skip to main content

SPA overview

The admin console (frontend/) is a client-rendered single-page app: Vite + React + TypeScript + Tailwind CSS v4, deployed to Cloudflare Workers (Static Assets) (the protovoc Worker). The respondent-facing survey runtime is part of the same bundle but renders standalone (no shell, no auth).

Entry & routing

frontend/src/main.tsx mounts <App /> into #root under React StrictMode (Inter is loaded via @fontsource-variable/inter).

frontend/src/App.tsx defines the route tree under BrowserRouter:

  • Public routes are matched first and live outside auth/shell — /s/:id (survey completion) and /s/:id/opt-out.
  • Everything else goes through <Gate />, which reads the session from useAuth():
    • while loading, it shows a spinner;
    • a signed-in user gets AuthedAppClerkWorkspaceGateAppShell → the protected Routes;
    • a signed-out visitor gets AnonApp — just the full-page ClerkLogin.

Protected routes cover the Analyse surfaces (/, /responses, /verbatim, /kpi, /reports, /alerts), Engage/Configure pages, and the /admin/* hub. / is the Dashboards home; the dashboard builder lives at /dashboards/builder.

AppShell

frontend/src/components/layout/AppShell.tsx is the authenticated chrome:

  • A collapsible teal sidebar grouped into sections (Analyse / Engage / Configure / Develop); the collapsed state persists in localStorage (voc_sidebar_collapsed).
  • A sticky header with the sidebar toggle, a context-aware Back link (sub-pages return to their hub via backTarget), the OrgSwitcher, GlobalSearch, the account link, and Sign out.
  • The shared Analyse filter bar (AnalyseFilterBar) renders only on Analyse routes (isAnalyseRoute).
  • Admin console banners (api.activeConsoleMessages()) render as dismissible info/warning strips.
  • The whole shell is wrapped in <FilterProvider>, so the global filter state is available to every page beneath it (see Filters).

useIdleLogout(signOut) signs the user out after inactivity.

Auth & session

Auth is Clerk, driven directly from the SPA via @clerk/react; the API only verifies the session JWT. frontend/src/main.tsx wraps the app in <ClerkProvider> (publishable key from VITE_CLERK_PUBLISHABLE_KEY), and sign-in / org-onboarding render through Clerk components (frontend/src/components/ClerkAuthUI.tsx). The available sign-in methods (email code, Google, SSO) are configured in the Clerk dashboard, not in this codebase.

  • frontend/src/lib/clerk-auth.tsx — backs useAuth() on top of Clerk's hooks. It exposes { user, loading, activeOrgId, configured, refresh, signOut }, and holds the current Clerk session JWT in a module slot so the API client can read it synchronously per request. The active Clerk Organization is what drives which tenant the API scopes to.
  • Org and member surfaces use Clerk components directly: <OrganizationSwitcher> (the OrgSwitcher), member management via useOrganization (UsersPage), and <UserProfile> (AccountPage).

The API client

frontend/src/lib/api/http.ts is the shared HTTP core (base URL from VITE_API_URL, or same-origin when empty). Domain-specific modules live beside it, and frontend/src/lib/api.ts re-exports their operations as a compatibility façade. The core req<T> helper:

  • attaches Authorization: Bearer <token> from the auth module slot;
  • on a 401 with a token, refreshes the bearer once (refreshBearer, de-duplicated so a burst of 401s triggers a single refresh) and retries; if that fails it signs out and redirects to /login;
  • unwraps the error envelope — the API returns errors as { error: { code, message } }, so req reads body.error.message and throws an Error with a .status property. Pages catch this to show the server's message.
  • returns undefined for 204, otherwise the parsed JSON.

reqText handles small CSV/text endpoints; reqBlob handles permission-checked durable export manifests and CSV parts without exposing R2 references. DTOs and query-string builders are split into types.ts and query.ts (see Filters).