Web Service
The Web service is a Next.js 16 application using the App Router. It renders the Cohort Discovery Service UI for three roles — admin, custodian-admin, and researcher — and communicates with the API exclusively through Next.js Server Actions.
- Repo:
cohort-discovery-service-web - Port: 3000 (dev)
- Stack: Next.js 16, React 19, TypeScript, MUI 7, Zustand, TanStack Query
Installation
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
API_BASE_URL |
Yes | http://localhost:8100 |
API service URL — used by Server Actions |
APPLICATION_MODE |
Yes | integrated |
standalone or integrated — see Deployment modes |
NEXT_PUBLIC_LOGIN_URL |
Yes | — | Redirect URL when unauthenticated. Also the base for outbound organisation links; falls back to links.organisation in the branding config |
NEXT_PUBLIC_TASK_URL |
Yes | http://localhost:8000/api |
Task endpoints, and the base for the footer's API-docs link |
NEXT_PUBLIC_USE_EXAMPLE_QUERY |
No | false |
Enable example query helpers (debug) |
NEXT_PUBLIC_USE_DEBUG_LOGS |
No | false |
Enable client-side debug logging |
COOKIE_TOKEN_NAME |
No | token |
Name of the session cookie |
HIDE_NAV |
No | — | Set to 1 to hide the header and top menu, for embedding in an iframe |
CONFIG_SUPPORT_URL |
No | — | Support site used by the support pop-out |
CONFIG_SERVICE_DESK_URL |
No | — | Service desk base URL |
CONFIG_SERVICE_DESK_SUPPORT_SUFFIX |
No | — | Path appended for support requests |
CONFIG_SERVICE_DESK_REPORT_BUG_SUFFIX |
No | — | Path appended for bug reports |
DEFAULT_TABLE_REFRESH_INTERVAL |
No | — | Table auto-refresh interval (ms) |
DEFAULT_MAX_INVALID_REASONS |
No | — | Maximum invalid-reason messages shown at once |
DEFAULT_SEARCH_PREFETCH |
No | — | Search prefetch threshold |
DEFAULT_SEARCH_WAIT_TIME |
No | — | Search debounce (ms) |
DEFAULT_SEARCH_SUGGESTION_ROTATION |
No | — | Search suggestion rotation interval (ms) |
DEFAULT_LOCATION_MIN_RADIUS |
No | — | Minimum location search radius (metres) |
Build-time vs per-request
Every NEXT_PUBLIC_* value is inlined into the client bundle at build time and needs a rebuild to change. CONFIG_* and DEFAULT_* are server-only and read per request in src/providers/ServerDefaultProvider.tsx, so they can be changed without rebuilding.
Standalone mode login URL
For local standalone development, set:
For integrated mode, this points to the Gateway login endpoint.OIDC / SSO needs no web env vars
Standalone-mode SSO is configured entirely on the API (config/sso.php) — the web app just renders whatever GET /api/auth/methods returns and never holds IdP credentials itself. See Connecting an identity provider.
The admin Users view (src/modules/ViewUser/ViewUser.tsx) shows a user's Details, Sign-in (linked identities, with icons from ssoProviders.ts), Roles, and Workgroups — the latter two as read-only chip lists (src/components/ChipList/). UserTable.tsx can show the same as columns in the users list. All of it is display-only: role/workgroup assignment is config-driven on the API side (config/claimsaccesscontrol.php) — there's no edit UI here. See Access control policy.
Branding and customisation
Everything organisation-specific — product name, colours, logo, legal pages, and whether HDR UK chrome appears — lives in one swappable directory at the repo root:
A @branding/* → ./branding/* path alias in tsconfig.json is what makes it swappable: application code imports @branding/branding.config and @branding/theme, never a relative path. Replacing the directory's contents re-skins the app with no code changes and no environment variables.
Branding the web app — full field reference, worked examples, theme tokens, legal pages and the HDR UK chrome switch.
npm scripts
# Development
npm run dev # Dev server with Turbopack on port 3000
npm run dev-debug # Dev server with Node.js inspector attached
# Production
npm run build # Production build
npm run start # Production server on port 3001
# Linting
npm run lint # ESLint
npm run lint:fix # ESLint with auto-fix
npm run lint:workflows # GitHub Actions workflow lint (requires actionlint)
# Unit tests
npm run test # Jest — all tests
npm run test:watch # Jest watch mode
# E2E tests
npm run mock-api:start # Start mock API server on port 8100
npm run cy:open # Cypress interactive runner
npm run cy:run # Cypress headless
npm run e2e # Full pipeline: production build → Cypress
npm run e2e:dev # Full pipeline: dev server → Cypress
# E2E against a throwaway IdP (standalone SSO)
npm run oidc-mock:start # Start oidc-server-mock via docker compose
npm run cy:run:oidc # Run only the *.idp.cy.ts specs
npm run e2e:oidc # Full pipeline: mock IdP → build → Cypress → teardown
npm run oidc-mock:stop # Tear the mock IdP down
e2e, e2e:dev and e2e:oidc all force APPLICATION_MODE=standalone.
Testing
npm run test
npm run test:watch
# Run a specific file
npm run test -- --testPathPattern=src/utils/__tests__/rules
Tests run in a jsdom environment. The setup file is jest.setup.ts. The path alias @/ maps to src/.
The E2E suite requires a running API — use the mock API for isolated front-end testing:
# Start mock API (port 8100) then open Cypress
npm run mock-api:start &
npm run cy:open
# Or run headlessly against a full stack
npm run e2e # production build
npm run e2e:dev # dev server
Cypress config is in cypress.config.ts:
- Base URL: http://localhost:3000
- Viewport: 1280×900
- Videos and screenshots on failure are enabled
- JWT token generation tasks are available for role-based test scenarios
cypress/e2e/auth/oidc-login.cy.ts covers the sign-in options list and SSO error handling (session expired, invalid callback, IdP error passthrough). cypress/e2e/auth/oidc-login.idp.cy.ts covers the real round-trip against a throwaway IdP (oidc-server-mock, started via .github/oidc-mock/docker-compose.yml) — sign-in, cookie set, wrong credentials.
SSO cookie-setting logic lives in src/lib/authCookie.ts and is fully covered by Jest at src/lib/__tests__/authCookie.test.ts.
Project structure
branding/ Swappable deployment branding — see Customising your deployment
├── branding.config.ts
├── theme.ts
├── assets/logo.svg
└── legal/*.mdx
src/
├── app/ Next.js App Router
│ ├── (protected)/ Authenticated routes (admin, custodian, researcher views)
│ ├── (public)/ Unauthenticated routes (login, etc.)
│ └── (error)/ Error boundary pages
├── actions/ Server Actions — one directory per domain
│ ├── admin/
│ ├── collection/
│ ├── concept/
│ ├── conceptSet/
│ ├── query/
│ ├── task/
│ └── workgroup/
├── modules/ Feature-level composite components
├── components/ Reusable atoms and molecules
│ ├── Table/ Use via useTable() hook
│ ├── TabsShell/ Page-level tab navigation
│ ├── Modal/ Dialogs
│ ├── SquareCheckbox/ MUI Checkbox wrapper
│ ├── ChipList/ Read-only chip list — used for roles/workgroups display
│ └── UserTable/
│ └── userFilters.ts Shared filtering logic for user lists
├── hooks/ Shared React hooks (useTable, useSearchParams, …)
├── lib/ API client helpers and auth logic
│ └── api.ts apiGet, apiPost, apiPut, apiDelete
├── utils/
│ └── ssoProviders.ts Provider slug → icon map for the SSO login buttons
├── config/
│ └── routes.ts Centralised route builders — always use this for URLs
├── types/ TypeScript type definitions
└── store/ Zustand global state (with persistence)
Coding patterns
API calls — always use Server Actions and shared helpers:
// src/actions/query/getQueries.ts
import { apiGet } from "@/lib/api";
export async function getQueries() {
return apiGet("/api/v1/queries");
}
Never use ad-hoc fetch() in client components. All backend calls go through Server Actions in src/actions/.
Routing — always use routes.ts:
import { routes } from "@/config/routes";
// Good
href={routes.query.detail(query.id)}
// Bad — hardcoded paths break when routes change
href={`/queries/${query.id}`}
State management:
| State type | Tool |
|---|---|
| Server / fetched data | Server Actions + React Query (TanStack) |
| Global client state | Zustand (src/store/) |
| Form state | React Hook Form + Yup |
MUI wrappers — use the project's abstractions:
| Use | Instead of |
|---|---|
<Table> + useTable() |
MUI <TableContainer> directly |
<TabsShell> |
MUI <Tabs> directly |
<Modal> |
MUI <Dialog> directly |
<SquareCheckbox> |
MUI <Checkbox> directly |
Caching:
- Read-only data: pass cacheOptions: { tags: ["queries"] } to apiGet
- Mutations: pass cacheOptions: { useCache: false } and call revalidateTag in the action
PR title convention
PR titles must match this format (enforced in CI):
feat(DP-1234): short description
fix(DP-5678): short description
fix!(DP-5678): breaking change description
RELEASE: vX.Y.Z
Accepted prefixes: feat, fix, chore, docs, style, refactor, test, perf
Pre-PR checklist
Run these before opening a pull request:
For changes touching standalone SSO, also run the IdP round-trip: