Skip to content

Admin console

The admin console is where FINNS staff look up members and adjust points, and where admins manage who on staff has access. It is a backend-for-frontend: the browser only ever talks to the console’s own Worker, and the Worker calls core server to server.

Repository: admin. Test: https://test-admin.finnsbali.com.

Framework
SvelteKit 2.70Svelte 5 (runes)TypeScript 6Vite 8
UI
shadcn-sveltebits-uiTailwind CSS 4Lucide iconsInter
Forms
SvelteKit form actionsuse:enhanceValidated in the Worker and again in core.
Sign-in
JumpCloud OIDCoauth4webapiPKCE + state + nonce
Sessions
Stateless sealed cookieAES-256-GCMHKDF keys8 h absolute; nothing stored server-side.
Runs on
Cloudflare Workers@sveltejs/adapter-cloudflare
Quality
VitestESLintPrettiersvelte-check
Screen Who What
Customers Any active staff Search by name, email, Keycloak subject, or the external id a partner quotes in a report (staff address a customer by subject only). See balance and the last 100 postings, each labelled with who posted it (partner, staff member, or FINNS for check-ins and bookings).
Customer → credit / debit Any active staff Adjust points with an optional reference. Debits can’t go below zero. To undo, post the opposite adjustment.
Staff Admins Add staff by email, change role (STAFF, ADMIN) or active status, unlink or delete, see each person’s change history.
Dashboard Any active staff Placeholder

Core enforces the rules, not the console: nobody can change their own role or status, at least one active admin must remain, and concurrent edits are refused.

flowchart LR
  classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
  classDef area fill:#E3F4F3,stroke:#13686C,color:#12191B
  classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
  classDef person fill:#E9724C,stroke:#C55A37,color:#ffffff

  staff(["Staff browser"]):::person
  subgraph worker["Cloudflare Worker · trident-admin"]
    direction TB
    hooks["hooks.server.ts<br/>guard: session + access + role"]:::area
    pages["+page.server.ts<br/>loads and form actions"]:::area
    client["core client<br/>Bearer service token<br/>X-Finns-Actor-Sub"]:::area
    hooks --> pages --> client
  end
  jc["JumpCloud<br/>OIDC"]:::ext
  core["Core<br/>/api/v1/admin"]:::ours

  staff -- "HTML, form posts<br/>sealed session cookie" --> hooks
  hooks -- "sign-in redirect" --> jc
  client -- "server to server" --> core

Two gates decide access: JumpCloud group membership decides who may sign in at all; core’s staff table decides whether they get in and with which role.

sequenceDiagram
  autonumber
  actor S as Staff
  box rgb(227,244,243) FINNS platform
    participant W as Admin Worker
    participant CORE as Core
  end
  box rgb(246,241,233) Third party
    participant JC as JumpCloud
  end

  S->>W: GET /auth/login
  Note over W: nowrap:Origin must be APP_ORIGIN.<br/>Seal {state, nonce, PKCE verifier, returnTo}<br/>in a 10-min transaction cookie
  W-->>S: 303 to JumpCloud
  S->>JC: Sign in
  JC-->>S: 303 /auth/callback?code&state
  S->>W: GET /auth/callback
  W->>JC: Exchange code (client secret + verifier)
  W->>JC: userinfo
  Note over W: nowrap:memberOf must include<br/>an allowed group
  W->>CORE: POST /api/v1/admin/staff-access/sign-ins {sub, email}
  Note over CORE: nowrap:Link the JumpCloud account<br/>on first sign-in, record it
  CORE-->>W: active (role) / disabled / none
  W-->>S: Set sealed session cookie, 303 to returnTo

On every protected request the Worker asks core again (GET /staff-access/{sub}), caching only “active” answers for 30 seconds. So disabling someone or changing their role takes effect within 30 seconds; removing them from the JumpCloud group takes effect at their next sign-in (at most 8 hours). If core can’t answer, the console returns 503: it never fails open.

sequenceDiagram
  autonumber
  actor S as Staff
  box rgb(227,244,243) FINNS platform
    participant W as Admin Worker
    participant CORE as Core
  end

  S->>W: Open customer page
  W->>CORE: GET /customers/{sub}
  CORE-->>W: Customer, balance, last 100 postings
  W-->>S: Page with hidden key = random UUID
  S->>W: POST ?/credit {points, reference, key}
  W->>CORE: POST /customers/{sub}/credits<br/>Idempotency-Key: key · X-Finns-Actor-Sub
  Note over CORE: nowrap:Posting with client STAFF,<br/>reason staff_adjustment, staff id
  CORE-->>W: 201 transaction
  W-->>S: Reload with a new key
  Note over S,CORE: nowrap:A resubmitted form reuses the key, so it replays instead of posting twice
  • Protected by default: every route outside (public)/ needs a session; the minimum role comes from the route folder, so a new admin page can’t be left open by mistake. The menu filter is cosmetic.
  • Configuration is validated on every request; a missing or bad setting returns 500, never an open console.
  • No identity-provider tokens are kept after sign-in; the session cookie holds only {sub, name, email, groups} and is under 3.8 KB.
  • Sign-out is POST-only and clears only the console’s session, so the user isn’t silently signed straight back in by JumpCloud.
  • Previews can’t sign in: sign-in only works on the configured origin.

Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).