KBase 2.0 web app. Vite + React 19 + TypeScript, file-based TanStack Router with TanStack Query, ORCID-only auth against the kbase auth service. Source repo for the KBase design system.
git clone <this-repo>
cd next-gen-ui
nvm use # Node 22 (see .nvmrc)
npm install
npm run dev # http://localhost:3000The dev server binds on 0.0.0.0 so it's reachable from non-localhost
hosts on your network. Set VITE_DEV_ALLOWED_HOSTS (comma-separated;
leading dot is Vite's subdomain wildcard) in .env.development.local
to add hosts.
| Script | What it does |
|---|---|
npm run dev |
Vite dev server, port 3000, HMR. |
npm run build |
Type-check route tree, bundle to dist/. |
npm run build:design-system |
Build the @kbase/design-system package to dist-design-system/. |
npm run preview |
Serve the built dist/ locally (smoke-test the prod bundle). |
npm run typecheck |
tsc --noEmit. |
npm run lint |
ESLint (flat config in eslint.config.js). |
npm run lint:fix |
ESLint with autofix. |
npm run format |
Prettier write. |
npm run format:check |
Prettier check (CI uses this). |
npm test |
Vitest, single run (CI uses this). |
npm run test:watch |
Vitest watch mode. |
CI runs lint → format:check → build → route-tree drift check →
typecheck → test. The route tree (src/routeTree.gen.ts) is
committed; the drift step fails CI if npm run build produces a
diff against the committed file.
- Build / dev: Vite 8.
- Framework: React 19 (StrictMode-on in dev).
- Routing: TanStack Router, file-based, codegen via the Vite
plugin (
autoCodeSplitting: true). Test files (*.test.tsx) are excluded from the route tree. - Server state: TanStack Query 5. One
QueryClientlives inmain.tsxand is exposed to the router viacreateRouter({ context: { queryClient } })so route loaders can callcontext.queryClient.ensureQueryData(...). - Schema validation: Zod at every network boundary. No untyped network responses leak into the app.
- Auth: ORCID-only via the kbase auth service; session token in
a
.kbase.uscookie shared with the legacy UI / narratives. Implementation lives atsrc/api/auth/. Seesrc/api/auth/README.mdfor the wire contract, flow, and trade-offs. - UI primitives: KBase design system at
src/design-system/(in-repo alias@kbase/design-system); also published as@kbase/design-systemon GitHub Packages npm. Seesrc/design-system/README.md. - Icons:
@phosphor-icons/react. - Tests: Vitest + Testing Library,
jsdomenvironment, MSW for HTTP stubbing.
src/
main.tsx React + Router bootstrap, QueryClient setup,
cross-tab auth sync, design-system token CSS.
styles.css App-shell styling (sidebar/header, .link, login bits).
routeTree.gen.ts Generated by the TanStack Router plugin. Do not edit.
routes/ File-based routes. The path on disk = the URL.
__root.tsx Root route. Auth gate (beforeLoad), error +
pending boundaries, layout split between
AppLayout (sidebar+header) and AuthLayout (no chrome).
index.tsx `/` KBase 2.0 home page. Signed-out visitors
are sent to `/portals` instead of `/login`.
account.tsx `/account` Identity + sessions.
portals.tsx `/portals` Public. Published-portals gallery.
portals.module.css Styles for the above, scoped to that route.
design-system.tsx `/design-system` Hidden. Design-system showcase.
login/
index.tsx `/login` Public. ORCID button + dev token paste.
continue.tsx `/login/continue` Public. OAuth callback.
api/auth/ ORCID auth subsystem. See README in this dir.
design-system/ KBase design system (canonical source). See README.
test/setup.ts jsdom + jest-dom + MSW server lifecycle.
Drop a file under src/routes/. The URL is the path on disk. The
TanStack plugin regenerates routeTree.gen.ts on dev / build.
- Private route (the default): just create the file. The root
gate in
__root.tsxprotects everything not inPUBLIC_ROUTES. - Public route: create the file and add its pathname to
PUBLIC_ROUTESinsrc/routes/__root.tsx(e.g./login,/login/continue).
Set the header title via staticData: { title: '…' } on the
route's createFileRoute config. The app shell pulls that from
the deepest matched route.
Two sets, and the distinction matters.
Dev-time VITE_*, read from .env* files and inlined by Vite.
These configure npm run dev only — a production image ignores
them.
| Var | Default (in code) | Notes |
|---|---|---|
VITE_AUTH_ORIGIN |
https://kbase.us |
Auth service origin. Empty means relative paths through the dev proxy. |
VITE_COOKIE_DOMAIN |
unset | Optional. .kbase.us for prod-like deploys; leave unset locally. |
VITE_DEV_ALLOWED_HOSTS |
unset | Comma-separated; leading dot is Vite's subdomain wildcard. For non-localhost dev hosts. |
Runtime, read by the container's entrypoint and rendered into
nginx.conf and index.html at start. Set these on the workload;
no rebuild is involved.
Unset and empty mean the same thing.
| Var | Not set means |
|---|---|
AUTH_ORIGIN |
no auth service in this deployment |
COOKIE_DOMAIN |
derive from the current host |
IDP_ORIGINS |
https://orcid.org (space-separated, for form-action) |
Leaving AUTH_ORIGIN out is a supported deployment, not a broken one:
public routes serve normally and sign-in reports itself as unavailable
rather than posting to a path that does not exist. That is the initial
rollout, before an auth route exists.
src/config.ts reads the rendered values from <meta name="config:*">
tags. An absent tag means a dev build, and falls back to the VITE_*
value; a tag still holding its __PLACEHOLDER__ means a container
whose operator did not set that variable, and reads as not configured.
.env.example is the documentation; .env.development overrides
for npm run dev and is committed. Personal overrides go in
.env.development.local (gitignored).
The auth-related env vars have deeper context in
src/api/auth/README.md.
Deploying on Rancher2: DEPLOYING.md.
docker build -t frontend .
docker run -e AUTH_ORIGIN=https://kbase.us -p 8080:8080 frontendNo build args. The image is environment-agnostic: the same bytes run on CI, staging, and prod, and get promoted by tag. Deploy config is rendered when the container starts, not baked into the bundle, so the artifact you tested is the artifact you ship.
Multi-stage layout:
deps:npm ciagainstpackage*.jsonwith the npm cache mounted.build:npm run build→dist/, plus.csp-script-hash.runtime:nginxinc/nginx-unprivileged:1.27-alpineon port 8080, servesdist/with the SPA fallback.
At start, docker-entrypoint.d/05-render-config.sh renders both
nginx.conf and index.html from pristine templates kept outside
the doc root. From templates rather than in place so the render is
idempotent — the entrypoint runs again on every restart, and an
in-place edit would consume its own placeholders. It exits non-zero
if a __PLACEHOLDER__ survives where a real value was required — a
stray one in a CSP is a broken policy — so a container that cannot be
configured correctly does not serve traffic.
AUTH_ORIGIN lands in two places at once — the CSP's connect-src
and form-action, and the <meta name="config:auth-origin"> the
bundle reads. That coupling is the point: a bundle calling one
origin while the CSP allows another produces a blocked request with
a confusing console message.
nginx.conf sets:
Content-Security-Policy:connect-srcis'self' <AUTH_ORIGIN>;form-actionis'self' <AUTH_ORIGIN> <IDP_ORIGINS>(the IDP entry is required because modern browsers checkform-actionagainst every redirect target, and the ORCID hop falls under this);script-srcis'self' '<sha256 of the theme-init script>'(that script is inlined intoindex.html, and CSP blocks inline script unless it is named by hash; the hash is computed at build time by thetheme-initplugin invite.config.tsand written to.csp-script-hashfor the container entrypoint, so editing the script cannot leave a stale hash behind);font-srcis'self' data:, because Vite inlines font subsets under 4 KB asdata:URIs while the rest ship as files. The fonts are self-hosted via@fontsource, so no Google Fonts origin is allowlisted.X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originX-Frame-Options: DENY- Long-lived
Cache-Control: public, immutablefor/assets/*(Vite's fingerprinted bundles);no-cacheforindex.html.
The full security header set is repeated in every location
block. add_header doesn't inherit when a child location
defines its own. The repetition is fenced with
# --- BEGIN security headers (keep in sync) --- … # --- END ---
comments so PR diffs surface drift.
.github/workflows/ci.yml: runs on every push tomainand every PR. Lint → format check → build (+ route-tree drift check) → typecheck → test..github/workflows/docker.yml: builds the multi-arch (linux/amd64,linux/arm64) image and pushes to GHCR with build provenance attestation. Tags:- PRs from this same repo →
pr-<n>,prerelease,sha-<short>. - GitHub Releases → semver (
x.y.z,x.y,x) pluslatestif not a prerelease. - Fork PRs do not trigger a push (avoids leaking write-scoped tokens to untrusted code).
workflow_dispatchis allowed for manual rebuilds.
- PRs from this same repo →
.github/workflows/design-system.yml: builds the@kbase/design-systempackage on every PR and push tomain; uploads the.tgzas a workflow artifact. On a release with ads-v*tag, derives the version from the tag, publishes to GitHub Packages npm, and attaches the tarball to the release. Independent of the app'svX.Y.Zreleases. Details:src/design-system/README.md#release.
Vitest + Testing Library, jsdom environment. MSW (msw) is the
default network stub; spin up a server in src/test/setup.ts so
every test runs against the same handler set.
Conventions:
- Test files are colocated with the unit under test (
foo.test.ts,foo.test.tsx). The router plugin is configured to ignore them. *.test.tsxfor components,*.test.tsfor pure functions.- Prefer Testing Library queries over implementation details; assert on roles/labels, not class names.
- Stub the auth service via MSW. For route-level tests, use
createMemoryHistory+ a freshQueryClientso cache state doesn't bleed across tests.
The auth subsystem's high-leverage coverage is summarised in
src/api/auth/README.md.
- TypeScript strict. No
anyin committed code. Use Zod-derived types at boundaries. - No global mutable state. The
QueryClientis dependency-injected via the router context. - Don't mix layers in
api/. Each subsystem undersrc/api/isschemas → client → cookie → queries → hooks → index, each importing only from layers below. New backends (workspace, narrative, …) get their own sibling directory with the same shape. - Route styles in a sibling
*.module.css, app-shell styling instyles.css, design-system tokens for everything else.
npm run dev won't load, "host not allowed":
Set VITE_DEV_ALLOWED_HOSTS in .env.development.local
(comma-separated; leading dot is Vite's subdomain wildcard).
Auth-related issues (cookie not landing, 403 on sessions,
Bearer 401, etc.). See the troubleshooting section in
src/api/auth/README.md.
src/api/auth/README.md: wire contract, login flow,safeRedirect, cross-tab sync, token storage trade-off, local dev, troubleshooting.DEPLOYING.md: Rancher2 deployment — image tags, container facts, runtime configuration, and what the Ingress has to do.
