Skip to content

Portal

platform/portal: SvelteKit 2, Svelte 5 (runes only — no $:, no export let), strict TypeScript. Deployed to Cloudflare Pages with @sveltejs/adapter-cloudflare. Device pages, enrolment, callback and the Monsieur Wifi hand-off render client-side only (ssr = false).

Stack

Area Choice
Auth oidc-client-ts, authorization code + PKCE against Zitadel
RPC @connectrpc/connect-web v1, @bufbuild/protobuf v1
Data fetching @tanstack/svelte-query (src/lib/api/queries/)
Tables @tanstack/svelte-table
Charts layerchart, d3-scale, d3-shape
Console @xterm/xterm
i18n @inlang/paraglide-js — see i18n
Tests Vitest (unit and Svelte SSR render tests), Playwright, e2e/check-*.mjs page checks

Talking to nexus

src/lib/api/transport.ts builds one Connect transport to VITE_NEXUS_URL and one client per service: sessionClient, orgClient, gatewayClient, adminClient, usersClient. An interceptor adds Authorization: Bearer <access_token> from the OIDC user manager.

  • Connect-JSON by default; VITE_NEXUS_BINARY=true switches to binary protobuf.
  • int64 is a string in JSON. Generated message types use bigint for int64 fields; JSON decoded by hand (from a bytes field or a WebSocket frame) carries strings.
  • bytes fields defeat typing. state_data, config_data and similar are JSON documents in a Uint8Array; decoding them and casting with as T is an assertion, not a check. Prefer the typed state field on GetGatewayResponse / GetGatewayStateResponse.
  • Mappers drop unnamed fields. detailToGateway() and gatewayToConfig() in the gateway store name fields explicitly; src/lib/mapper-completeness.test.ts reads the source and fails when a field of a config type is not named by both mappers.
  • Permissions come from the server. ListGateways and GetGateway return caller_actions per device; src/lib/can.ts answers "should this control be offered" from it. Absent actions mean unknown and answer true. Nothing in the portal is a security boundary; every RPC gates for itself.

Authentication

src/lib/auth/config.ts configures oidc-client-ts with VITE_ZITADEL_ISSUER and VITE_ZITADEL_CLIENT_ID, redirect /callback, scope openid profile email urn:zitadel:iam:org:projects:roles, tokens in localStorage, silent renew on. No project-audience scope is requested, so the token's audience is the portal client's own project and only the matching environment's nexus accepts it.

After sign-in, Me supplies roles and permissions (src/lib/auth/store.svelte.ts). A user with no permission at all sees NoOrganization instead of the app shell.

Environment variables

Variable Purpose
VITE_NEXUS_URL nexus base URL (default http://localhost:4001)
VITE_REALTIME_WS_URL realtime Worker WebSocket base URL (default ws://localhost:8787)
VITE_ZITADEL_ISSUER, VITE_ZITADEL_CLIENT_ID OIDC
VITE_MRWIFI_URL Monsieur Wifi link in the sidebar
VITE_RADIUS_URL radius-ctrl UI link on the hub page
VITE_ENV development, staging, production (About badge)
VITE_NEXUS_BINARY true for binary protobuf
VITE_DEV_AUTH true sends a fixed bearer, for a nexus running with NEXUS_DEV_AUTH=true

Guides

  • Layout & Navigation — app shell, sidebar, secondary panel, breadcrumb.
  • Stores — gateway store, realtime subscription, preferences.
  • Components — key UI components.
  • i18n — Paraglide and locale coverage.