From 22e8a82fa140a5c8efa0b7bb226287d92f9c8ab3 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:47:37 +0100
Subject: [PATCH 1/5] Portal: add 'Open in browser' CTA to the welcome hero
(#6944)
## Screenshots
---
.../editor/public/locales/en-US/translation.toml | 1 +
.../editor/src/portal/components/WelcomeBanner.tsx | 13 ++++++++++++-
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 2daef44903..100604ac01 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7962,6 +7962,7 @@ ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
installEditor = "Install the Editor"
inviteTeammates = "Invite teammates"
+openInBrowser = "Open in browser"
perks = "Free forever · Self-hostable"
subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO."
title = "Welcome to"
diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx
index 8e9f71af29..ee64be1011 100644
--- a/frontend/editor/src/portal/components/WelcomeBanner.tsx
+++ b/frontend/editor/src/portal/components/WelcomeBanner.tsx
@@ -2,10 +2,12 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
+import { EDITOR_URL } from "@portal/auth/editorUrl";
import {
DownloadIcon,
UsersIcon,
EditorIcon,
+ ExternalLinkIcon,
SearchIcon,
SourcesIcon,
PoliciesIcon,
@@ -156,13 +158,22 @@ export function WelcomeBanner({ footer }: WelcomeBannerProps) {
}
+ onClick={() => {
+ window.location.href = EDITOR_URL;
+ }}
+ >
+ {t("portal.welcome.openInBrowser")}
+
+
}
onClick={() => setActiveView("editor")}
>
{t("portal.welcome.installEditor")}
}
onClick={() => setActiveView("users")}
>
From 2091874050735c0e47a3b6b611962e998550ba4e Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:50:50 +0100
Subject: [PATCH 2/5] Remove in-app portal mocks (#6910)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The portal no longer uses mock data — it always talks to the real
backend. Mocks still power Storybook and tests.
- Mocks button and all the in-app MSW machinery removed.
- Types the app needs moved out of mock files and into the api layer, so
the app no longer depends on `mocks/` at all.
- One deliberate exception for the onboarding tour (#6926):
`enablePortalDemoData()` fills the views with example data while a tour
runs, with zero cost the rest of the time.
Heads up: views without a real backend endpoint yet now show empty/error
states in dev.
---
frontend/editor/.env.proprietary | 10 +-
.../public/locales/en-US/translation.toml | 270 ++++++++++-
.../editor/src/core/i18n/translationAudit.ts | 15 +
.../src/portal-saas/PortalProviders.tsx | 2 +-
frontend/editor/src/portal/MOCKS.md | 48 +-
.../editor/src/portal/PortalProviders.tsx | 2 +-
frontend/editor/src/portal/api/agents.ts | 129 +++++-
.../editor/src/portal/api/demoData.test.ts | 53 +++
frontend/editor/src/portal/api/demoData.ts | 54 +++
frontend/editor/src/portal/api/docs.ts | 124 ++++-
frontend/editor/src/portal/api/documents.ts | 145 +++++-
.../editor/src/portal/api/editorDeploy.ts | 166 ++++++-
frontend/editor/src/portal/api/http.ts | 14 +
.../editor/src/portal/api/infrastructure.ts | 302 ++++++++++--
frontend/editor/src/portal/api/link.ts | 59 ++-
.../editor/src/portal/api/notifications.ts | 21 +-
frontend/editor/src/portal/api/pipelines.ts | 5 +-
frontend/editor/src/portal/api/policies.ts | 432 ++++++++++++++++--
frontend/editor/src/portal/api/procurement.ts | 203 +++++++-
.../editor/src/portal/api/sdkComponents.ts | 146 +++++-
frontend/editor/src/portal/api/search.ts | 8 +-
frontend/editor/src/portal/api/settings.ts | 86 +++-
frontend/editor/src/portal/api/users.ts | 226 ++++++++-
.../editor/src/portal/components/Header.tsx | 8 +-
.../src/portal/components/MocksToggle.css | 51 ---
.../src/portal/components/MocksToggle.tsx | 55 ---
.../LinkedInstancesTable.stories.tsx | 3 +-
.../components/catalogue/ComponentCard.tsx | 4 +-
.../catalogue/ComponentDetailModal.tsx | 10 +-
.../components/documents/DocumentAudit.tsx | 2 +-
.../components/documents/DocumentDrawer.tsx | 2 +-
.../components/documents/DocumentOverview.tsx | 2 +-
.../components/documents/ReviewQueueTable.tsx | 2 +-
.../editor-admin/InstanceHealthTable.tsx | 2 +-
.../policies/PolicyCategoryCard.tsx | 4 +-
.../components/policies/PolicyDetailPanel.tsx | 6 +-
.../components/policies/PolicyFieldRow.tsx | 17 +-
.../components/policies/PolicySetupWizard.tsx | 10 +-
.../components/policies/storyFixtures.ts | 5 +-
.../components/procurement/DealJourney.tsx | 4 +-
.../components/procurement/DocumentLedger.tsx | 4 +-
.../procurement/LockedState.stories.tsx | 2 +-
.../procurement/StageStepper.stories.tsx | 2 +-
.../components/procurement/StageStepper.tsx | 4 +-
.../components/users/InviteMemberModal.tsx | 15 +-
.../src/portal/contexts/TierContext.tsx | 45 +-
frontend/editor/src/portal/mocks/agents.ts | 117 +----
frontend/editor/src/portal/mocks/browser.ts | 26 --
frontend/editor/src/portal/mocks/docs.ts | 113 +----
frontend/editor/src/portal/mocks/documents.ts | 134 +-----
.../editor/src/portal/mocks/editorDeploy.ts | 144 +-----
.../editor/src/portal/mocks/handlers/index.ts | 27 --
.../editor/src/portal/mocks/handlers/link.ts | 2 +-
.../portal/mocks/handlers/notifications.ts | 3 +-
.../src/portal/mocks/handlers/policies.ts | 8 +-
.../src/portal/mocks/handlers/procurement.ts | 6 +-
.../editor/src/portal/mocks/infrastructure.ts | 249 +---------
frontend/editor/src/portal/mocks/link.ts | 65 +--
.../editor/src/portal/mocks/notifications.ts | 23 +-
frontend/editor/src/portal/mocks/policies.ts | 361 +--------------
.../editor/src/portal/mocks/preference.ts | 31 --
.../editor/src/portal/mocks/procurement.ts | 199 +-------
.../src/portal/mocks/procurementMachine.ts | 2 +-
.../editor/src/portal/mocks/sdkComponents.ts | 129 +-----
frontend/editor/src/portal/mocks/search.ts | 13 +-
frontend/editor/src/portal/mocks/settings.ts | 86 +---
.../editor/src/portal/mocks/startIfEnabled.ts | 12 -
frontend/editor/src/portal/mocks/users.ts | 217 +--------
frontend/editor/src/portal/vite-env.d.ts | 2 -
.../components/policies/PolicySetupWizard.tsx | 2 +-
.../routes/adminRouteExtensions.tsx | 6 +-
71 files changed, 2454 insertions(+), 2302 deletions(-)
create mode 100644 frontend/editor/src/portal/api/demoData.test.ts
create mode 100644 frontend/editor/src/portal/api/demoData.ts
delete mode 100644 frontend/editor/src/portal/components/MocksToggle.css
delete mode 100644 frontend/editor/src/portal/components/MocksToggle.tsx
delete mode 100644 frontend/editor/src/portal/mocks/browser.ts
delete mode 100644 frontend/editor/src/portal/mocks/preference.ts
delete mode 100644 frontend/editor/src/portal/mocks/startIfEnabled.ts
diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary
index 0ee7e66bc9..4d25e380da 100644
--- a/frontend/editor/.env.proprietary
+++ b/frontend/editor/.env.proprietary
@@ -13,10 +13,12 @@
# VITE_EDITOR_URL=http://localhost:5173/).
VITE_EDITOR_URL=/
-# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default
-# (on in dev, off in production builds). Set "false" to run against the real
-# backend.
-VITE_PORTAL_MOCKS=
+# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
+# linking (both values are public). Set per deploy; absent -> the account-link
+# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
+# project the local backend links against.
+VITE_SAAS_SUPABASE_URL=
+VITE_SAAS_SUPABASE_ANON_KEY=
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 100604ac01..6228914b66 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -5980,6 +5980,16 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
+[policies.docType]
+Contracts = "Contracts"
+"Financial reports" = "Financial reports"
+"HR records" = "HR records"
+Insurance = "Insurance"
+Invoices = "Invoices"
+"Legal filings" = "Legal filings"
+"Medical / PHI" = "Medical / PHI"
+"Tax documents" = "Tax documents"
+
[policies.enforcement]
applying = "Applying {{names}}"
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
@@ -6001,6 +6011,65 @@ export = "Enforcing before export"
input = "Enforcing on import"
print = "Enforcing before print"
+[policies.field]
+accessLog = "Access log"
+archiveAfter = "Archive after"
+auditTrail = "Audit trail"
+belowThreshold = "Below threshold"
+destination = "Destination"
+frameworks = "Frameworks"
+immutableHold = "Immutable hold"
+keepFor = "Keep for"
+minConfidence = "Min confidence"
+notify = "Notify on route"
+onViolation = "When non-compliant"
+webhookUrl = "Webhook URL"
+
+[policies.fieldOption.archiveAfter]
+"1 year" = "1 year"
+"30 days" = "30 days"
+"90 days" = "90 days"
+Never = "Never"
+
+[policies.fieldOption.belowThreshold]
+"Flag for review" = "Flag for review"
+Hold = "Hold"
+"Route to bucket" = "Route to bucket"
+
+[policies.fieldOption.destination]
+Documents = "Documents"
+"S3 bucket" = "S3 bucket"
+SharePoint = "SharePoint"
+Webhook = "Webhook"
+
+[policies.fieldOption.frameworks]
+FedRAMP = "FedRAMP"
+GDPR = "GDPR"
+HIPAA = "HIPAA"
+"ISO 27001" = "ISO 27001"
+"PCI DSS" = "PCI DSS"
+"SOC 2" = "SOC 2"
+
+[policies.fieldOption.keepFor]
+"1 year" = "1 year"
+"3 years" = "3 years"
+"30 days" = "30 days"
+"7 years" = "7 years"
+Indefinite = "Indefinite"
+
+[policies.fieldOption.minConfidence]
+"60%" = "60%"
+"70%" = "70%"
+"80%" = "80%"
+"90%" = "90%"
+"95%" = "95%"
+
+[policies.fieldOption.onViolation]
+"Auto-redact PHI" = "Auto-redact PHI"
+"Block export" = "Block export"
+"Flag for review" = "Flag for review"
+"Quarantine document" = "Quarantine document"
+
[policies.fields]
selectedCount = "{{count}} selected"
@@ -6526,6 +6595,19 @@ componentSpendMtd = "Component spend (MTD)"
embedsThisMonth = "Embeds this month"
inBeta = "In beta"
+[portal.components.billingUnit]
+approval = "approval"
+check = "check"
+event = "event"
+render = "render"
+review = "review"
+session = "session"
+signature = "signature"
+
+[portal.components.maturity]
+beta = "Beta"
+ga = "GA"
+
[portal.componentsView]
subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props."
title = "Components"
@@ -6646,7 +6728,14 @@ subtitle = "Every document your org has processed, with its full processing reco
title = "Documents"
[portal.documents.audit]
+approved = "Approved"
+archived = "Archived"
+elevation = "Elevation"
empty = "No events recorded yet."
+extracted = "Processed"
+flagged = "Needs Review"
+ingested = "Ingested"
+reviewed = "In Review"
[portal.documents.drawer]
sectionsAriaLabel = "Document detail sections"
@@ -6697,6 +6786,12 @@ user = "User"
description = "As sources feed documents into your pipelines they'll appear here for review."
title = "No documents in the queue"
+[portal.documents.status]
+error = "Error"
+flagged = "Needs Review"
+inReview = "In Review"
+processed = "Processed"
+
[portal.documents.table]
auto = "Auto"
editorAction = "Editor"
@@ -6771,6 +6866,12 @@ title = "Service token"
description = "A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline."
title = "Rotate running instances"
+[portal.editorAdmin.status]
+degraded = "Degraded"
+healthy = "Healthy"
+offline = "Offline"
+pairing = "Pairing"
+
[portal.editorAdmin.targets]
instanceCount_one = "{{count}} instance"
instanceCount_other = "{{count}} instances"
@@ -7159,14 +7260,6 @@ models = "Models"
security = "Security"
storage = "Storage"
-[portal.mocks.label]
-off = "Mocks OFF"
-on = "Mocks ON"
-
-[portal.mocks.tooltip]
-off = "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
-on = "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
-
[portal.nav]
agent-builder = "Agent Builder"
components = "Components"
@@ -7314,6 +7407,90 @@ title = "Policies"
comingSoon = "Coming soon"
notSetUp = "Not set up"
+[portal.policies.categories.compliance]
+desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
+label = "Compliance"
+
+[portal.policies.categories.ingestion]
+desc = "Classify documents, extract structured data, enforce naming conventions, and normalize pages."
+label = "Ingestion"
+
+[portal.policies.categories.retention]
+desc = "Set how long documents are kept, when to archive, and when to delete."
+label = "Retention"
+
+[portal.policies.categories.routing]
+desc = "Auto-route documents to the right team, folder, or system."
+label = "Routing"
+
+[portal.policies.categories.security]
+desc = "Detect PII, redact, strip active content, and watermark documents."
+label = "Security"
+
+[portal.policies.config]
+scopeAll = "All documents"
+
+[portal.policies.config.compliance]
+summary = "Validates documents against regulatory frameworks before they leave the system."
+
+[portal.policies.config.compliance.fields]
+accessLog = "Access log"
+auditTrail = "Audit trail"
+frameworks = "Frameworks"
+onViolation = "When non-compliant"
+
+[portal.policies.config.compliance.rules]
+0 = "Framework scan"
+1 = "Enforce action"
+2 = "Audit trail"
+
+[portal.policies.config.ingestion]
+summary = "Classifies documents, extracts structured data, enforces naming, and normalizes pages."
+
+[portal.policies.config.ingestion.fields]
+belowThreshold = "Below threshold"
+minConfidence = "Min confidence"
+
+[portal.policies.config.ingestion.rules]
+0 = "Classify"
+1 = "Extract"
+2 = "Name"
+3 = "Normalize"
+
+[portal.policies.config.retention]
+summary = "Enforces how long documents are kept, when to archive, and when to delete."
+
+[portal.policies.config.retention.fields]
+archiveAfter = "Archive after"
+immutableHold = "Immutable hold"
+keepFor = "Keep for"
+
+[portal.policies.config.retention.rules]
+0 = "Retention hold"
+1 = "Auto-archive"
+2 = "Deletion block"
+
+[portal.policies.config.routing]
+summary = "Routes documents to the right destination based on type and classification."
+
+[portal.policies.config.routing.fields]
+destination = "Destination"
+notify = "Notify on route"
+webhookUrl = "Webhook URL"
+
+[portal.policies.config.routing.rules]
+0 = "Auto-classify"
+1 = "Route to folder"
+2 = "Webhook notify"
+
+[portal.policies.config.security]
+summary = "Detects and redacts PII, strips active content (JavaScript), and watermarks documents."
+
+[portal.policies.config.security.rules]
+0 = "Redact PII"
+1 = "Remove JavaScript"
+2 = "Watermark"
+
[portal.policies.detail]
enforces = "Enforces"
onEveryExport = "On every export"
@@ -7338,6 +7515,14 @@ runNow = "Run now"
description = "Documents will appear here once this policy runs."
title = "No activity yet"
+[portal.policies.endpoints]
+addWatermark = "Watermark"
+autoRedact = "Redact PII"
+compressPdf = "Compress"
+flatten = "Flatten"
+ocrPdf = "OCR"
+sanitizePdf = "Remove JavaScript"
+
[portal.policies.offline]
description = "Your policies are saved and will appear once the connection is restored."
retry = "Retry"
@@ -7585,6 +7770,31 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
title = "From trial to live, one guided path"
trialTitle = "Enterprise trial"
+[portal.procurement.journeySteps.agreement]
+blurb = "One signature covers MSA, order form, EULA and DPA."
+gatingAction = "Review and sign your agreement"
+label = "Agreement"
+
+[portal.procurement.journeySteps.implementation]
+blurb = "Provision your workspace and run the go-live playbook."
+gatingAction = "Provisioning your workspace"
+label = "Implementation"
+
+[portal.procurement.journeySteps.payment]
+blurb = "Pay by card, bank transfer, or against a purchase order."
+gatingAction = "Confirm payment"
+label = "Payment"
+
+[portal.procurement.journeySteps.quote]
+blurb = "Review committed-volume pricing and contract term."
+gatingAction = "Accept your quote"
+label = "Quote"
+
+[portal.procurement.journeySteps.trial]
+blurb = "Evaluate Stirling against your documents and workflows."
+gatingAction = "Build your quote"
+label = "Trial"
+
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
@@ -7957,6 +8167,50 @@ title = "Session expired"
[portal.users]
title = "Users"
+[portal.users.roles]
+subtitle = "Every role exists on every plan — what each one can do is fixed across the org."
+title = "Roles"
+
+[portal.users.roles.admin]
+label = "Admin (Org owner)"
+summary = "Full governance over the workspace, settings and members."
+
+[portal.users.roles.admin.permissions]
+0 = "Manage users, teams and roles"
+1 = "Manage all integrations incl. S3 connections"
+2 = "Grant or revoke portal access"
+3 = "Everything Team Owner can do"
+
+[portal.users.roles.guest]
+label = "Guest"
+summary = "Limited or web-only access; cannot hold personal configs."
+
+[portal.users.roles.guest.permissions]
+0 = "Web-only / demo usage"
+1 = "No API keys or integrations"
+2 = "No portal access"
+3 = "Read-only where shared"
+
+[portal.users.roles.member]
+label = "Member"
+summary = "Regular user — works with shared resources and their own configs."
+
+[portal.users.roles.member.permissions]
+0 = "Use the editor and shared integrations"
+1 = "Create personal API & MCP configs"
+2 = "See team configs shared with them"
+3 = "No S3 or workspace management"
+
+[portal.users.roles.team_owner]
+label = "Team owner"
+summary = "Owns a team — manages its members' resources and shared configs."
+
+[portal.users.roles.team_owner.permissions]
+0 = "Create & manage the team's S3 connections"
+1 = "Manage team-owned integration configs"
+2 = "Portal access via the default policy"
+3 = "Everything Member can do"
+
[portal.welcome]
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
diff --git a/frontend/editor/src/core/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts
index e2cc309c72..e581689070 100644
--- a/frontend/editor/src/core/i18n/translationAudit.ts
+++ b/frontend/editor/src/core/i18n/translationAudit.ts
@@ -89,6 +89,21 @@ export const I18N_PROJECTS: TranslationProject[] = [
// components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the
// static scan.
/^portal\.sources\.types\./,
+ // Portal catalogue copy stored as i18n keys in api/
.ts constants
+ // (label maps, role/policy/journey catalogues) and rendered via
+ // t(constant), invisible to the static scan.
+ /^portal\.documents\.(status|audit)\./,
+ /^portal\.editorAdmin\.status\./,
+ /^portal\.components\.(maturity|billingUnit)\./,
+ /^portal\.home\.(pipelineTemplates|pipelineStages)\./,
+ /^portal\.procurement\.journeySteps\./,
+ /^portal\.users\.roles\./,
+ /^portal\.policies\.(categories|config|endpoints)\./,
+ // Policy field/option/doc-type display copy is looked up with keys
+ // derived from catalogue data (t(`policies.fieldOption.${key}.${opt}`))
+ // in both PolicyFieldRows and the setup wizards — invisible to the
+ // static scan. The raw catalogue value stays the stored fallback.
+ /^policies\.(field|fieldOption|docType)\./,
],
minUsedKeys: 100,
minLocaleKeys: 100,
diff --git a/frontend/editor/src/portal-saas/PortalProviders.tsx b/frontend/editor/src/portal-saas/PortalProviders.tsx
index 161b942582..3c702921ea 100644
--- a/frontend/editor/src/portal-saas/PortalProviders.tsx
+++ b/frontend/editor/src/portal-saas/PortalProviders.tsx
@@ -11,7 +11,7 @@ import { PortalChrome } from "@portal/components/PortalChrome";
*/
export function PortalProviders() {
return (
-
+
diff --git a/frontend/editor/src/portal/MOCKS.md b/frontend/editor/src/portal/MOCKS.md
index 0cd41ee9a8..84fb8d97cd 100644
--- a/frontend/editor/src/portal/MOCKS.md
+++ b/frontend/editor/src/portal/MOCKS.md
@@ -1,25 +1,33 @@
# Portal data layer — mock backend & handover
-The portal is **mock-driven**. Every screen fetches real HTTP requests through a
-thin typed API layer; in dev and Storybook those requests are intercepted by
-[MSW](https://mswjs.io/) and answered with fixture data. Pointing the portal at a
-**real backend** is a matter of *not registering MSW* — no component or API-layer
-code changes.
+The mocks here are for **Storybook and tests** — the running portal hits the
+real network. Every screen fetches real HTTP requests through a thin typed API
+layer; in Storybook (via `msw-storybook-addon`, see `.storybook/preview.tsx`)
+and in vitest those requests are intercepted by [MSW](https://mswjs.io/) and
+answered with fixture data.
+
+One deliberate in-app exception: `api/demoData.ts` can answer `apiClient`
+calls from these same handlers while explicitly enabled (the onboarding tour
+does this so views render populated). No service worker, no interception —
+msw and the fixtures load lazily on first enable and stop answering on
+disable.
## The three layers
```
-view (useAsync) ──► api/.ts ──► httpJson(fetch) ──► MSW handler (dev) ──► fixture builder
- (the contract) └► real backend (prod) ─┘
+view (useAsync) ──► api/.ts ──► httpJson(fetch) ──► real backend (app)
+ (the contract) └► MSW handler (Storybook/tests) ──► fixture builder
```
-1. **`api/.ts`** — thin, typed `httpJson` wrappers. **This is the backend
- contract.** Each function documents its endpoint (method, path, query params)
- and its response type. Nothing else in the app issues fetches.
+1. **`api/.ts`** — thin, typed `httpJson` wrappers **plus the canonical
+ TS types and app-owned constants. This is the backend contract.** Each
+ function documents its endpoint (method, path, query params) and its response
+ type. Nothing else in the app issues fetches, and nothing in the app imports
+ from `mocks/`.
2. **`mocks/handlers/.ts`** — MSW handlers that answer those endpoints
with `mocks/.ts` fixtures. Registered in `mocks/handlers/index.ts`.
-3. **`mocks/.ts`** — fixture builders **and the canonical TS types**
- (re-exported through `api/.ts`, so consumers import types from `api/`).
+3. **`mocks/.ts`** — fixture builders only, importing their types from
+ `api/.ts` (mocks depend on the contract, never the reverse).
`api/http.ts` is the single `fetch` wrapper (sets headers, throws `HttpError` on
non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.ts`).
@@ -34,15 +42,13 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
**demo shells** (local state, no submit endpoint yet) — wire these to real
POSTs during backend integration.
-## Swapping in a real backend
+## Implementing a surface against the real backend
-1. Stop registering MSW (`mocks/browser.ts` / the dev bootstrap) — or gate it on
- an env flag (it's already dev-only via `import.meta.env.DEV`).
-2. Make `httpJson` hit your API origin (add a `baseURL`/proxy in `api/http.ts`).
-3. Match the response shapes in `api/.ts` (the exported types are the spec).
-4. Delete `mocks/` once parity is confirmed. Optionally relocate the types from
- `mocks/.ts` into `api/` (or a `types/` module) so they no longer live
- beside fixtures — purely cosmetic; the `api/` re-exports already shield consumers.
+1. Make `httpJson` hit your API origin (add a `baseURL`/proxy in `api/http.ts`)
+ and match the response shapes in `api/.ts` (the exported types are
+ the spec).
+2. Keep the surface's handler in `mocks/handlers/` in sync — Storybook and the
+ tests still answer through it.
## Endpoint catalogue
@@ -82,4 +88,4 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
>
> **Policies** targets the **real** backend base `/api/v1/policies` (Stirling's
> `PolicyController`) rather than the mock `/v1/...` convention — its contract
-> mirrors the live policy engine, so MSW can be dropped with no code change.
+> mirrors the live policy engine.
diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx
index f03092fde8..35c9fc7e88 100644
--- a/frontend/editor/src/portal/PortalProviders.tsx
+++ b/frontend/editor/src/portal/PortalProviders.tsx
@@ -50,7 +50,7 @@ function LinkModalHost() {
export function PortalProviders() {
return (
-
+
diff --git a/frontend/editor/src/portal/api/agents.ts b/frontend/editor/src/portal/api/agents.ts
index 9956a3f66a..61a9e75906 100644
--- a/frontend/editor/src/portal/api/agents.ts
+++ b/frontend/editor/src/portal/api/agents.ts
@@ -1,18 +1,123 @@
import { apiClient } from "@portal/api/http";
-import type { AgentsResponse } from "@portal/mocks/agents";
import type { Tier } from "@portal/contexts/TierContext";
-export type {
- Agent,
- AgentStatus,
- AgentVersion,
- AgentsResponse,
- AgentsSummary,
- EvalCase,
- Scenario,
- ToolMode,
-} from "@portal/mocks/agents";
-export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents";
+/*
+ * An "agent" here is an AI agent that classifies, extracts from, and routes
+ * documents. The builder is its lifecycle surface: scenarios (named test
+ * cases), tool-access governance, an eval / golden set, and version history.
+ */
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Domain types */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type AgentStatus = "draft" | "published";
+
+/**
+ * Tool-access posture. `broad` lets the agent call any tool it can reach;
+ * `restricted` is allow-by-default minus an explicit deny list (the governance
+ * mode enterprise tenants use to fence agents away from sensitive tools).
+ */
+export type ToolMode = "broad" | "restricted";
+
+/** A named test case describing expected agent behaviour for a kind of input. */
+export interface Scenario {
+ id: string;
+ name: string;
+ /** What the agent is expected to do for this input. */
+ expectation: string;
+ /** Whether this scenario is currently exercised by the eval run. */
+ enabled: boolean;
+}
+
+/** A single golden-set check with its last-run outcome. */
+export interface EvalCase {
+ id: string;
+ name: string;
+ /** Last observed pass/fail; null when the case has never been run. */
+ passing: boolean | null;
+ /** Mean latency of the last run in milliseconds. */
+ latencyMs: number;
+}
+
+export interface AgentVersion {
+ /** Display label, e.g. "v3" or "v2-draft". */
+ version: string;
+ status: AgentStatus;
+ /** ISO timestamp the version was created. */
+ createdAt: string;
+ author: string;
+ /** One-line change summary. */
+ note: string;
+}
+
+export interface Agent {
+ id: string;
+ name: string;
+ /** One-line role description shown under the name in the selector. */
+ role: string;
+ status: AgentStatus;
+ /** Current working version, e.g. "v3" or "v2-draft". */
+ version: string;
+ model: string;
+ scenarios: Scenario[];
+ toolMode: ToolMode;
+ /** Tools the agent may not call when `toolMode` is "restricted". */
+ deniedTools: string[];
+ /** Count of golden-set cases currently passing. */
+ evalsPassing: number;
+ /** Total golden-set cases. */
+ evalsTotal: number;
+ evalCases: EvalCase[];
+ versions: AgentVersion[];
+}
+
+export interface AgentsSummary {
+ /** Agents in the "published" state. */
+ activeAgents: number;
+ /** Total agents regardless of status. */
+ totalAgents: number;
+ /** Mean eval pass-rate across all agents, 0..1. */
+ avgPassRate: number;
+ /** Total scenarios across all agents. */
+ totalScenarios: number;
+ /** Latest published version label across the fleet, e.g. "v3". */
+ latestPublished: string;
+}
+
+export interface AgentsResponse {
+ summary: AgentsSummary;
+ agents: Agent[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Presentation metadata (chip tone per status). Product copy, client-side. */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const AGENT_STATUS_TONE: Record = {
+ published: "success",
+ draft: "neutral",
+};
+
+/**
+ * Catalogue of tools an agent can be granted or denied. Surfaced as the chip
+ * palette in restricted mode so the deny list is picked from a known set
+ * rather than free-typed.
+ */
+export const TOOL_CATALOGUE = [
+ "extract.fields",
+ "classify.document",
+ "route.pipeline",
+ "lookup.crm",
+ "send.email",
+ "write.audit",
+ "read.pii",
+ "invoke.webhook",
+] as const;
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Endpoints */
+/* ──────────────────────────────────────────────────────────────────────── */
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
export async function fetchAgents(tier: Tier): Promise {
diff --git a/frontend/editor/src/portal/api/demoData.test.ts b/frontend/editor/src/portal/api/demoData.test.ts
new file mode 100644
index 0000000000..a978703203
--- /dev/null
+++ b/frontend/editor/src/portal/api/demoData.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ disablePortalDemoData,
+ enablePortalDemoData,
+ isPortalDemoDataActive,
+ resolveDemoResponse,
+} from "@portal/api/demoData";
+
+describe("portal demo data seam", () => {
+ afterEach(() => disablePortalDemoData());
+
+ it("is inert until enabled", async () => {
+ expect(
+ await resolveDemoResponse(
+ new URL("/v1/agents?tier=pro", window.location.origin),
+ {},
+ ),
+ ).toBeUndefined();
+ expect(isPortalDemoDataActive()).toBe(false);
+ });
+
+ it("answers from the fixture handlers while enabled", async () => {
+ await enablePortalDemoData();
+ const res = await resolveDemoResponse(
+ new URL("/v1/agents?tier=pro", window.location.origin),
+ {},
+ );
+ expect(res?.status).toBe(200);
+ const body = (await res?.json()) as { agents: unknown[] };
+ expect(body.agents.length).toBeGreaterThan(0);
+ });
+
+ it("releases back to the network on disable", async () => {
+ await enablePortalDemoData();
+ disablePortalDemoData();
+ expect(
+ await resolveDemoResponse(
+ new URL("/v1/agents?tier=pro", window.location.origin),
+ {},
+ ),
+ ).toBeUndefined();
+ });
+
+ it("returns undefined for routes no handler matches", async () => {
+ await enablePortalDemoData();
+ expect(
+ await resolveDemoResponse(
+ new URL("/v1/nope", window.location.origin),
+ {},
+ ),
+ ).toBeUndefined();
+ });
+});
diff --git a/frontend/editor/src/portal/api/demoData.ts b/frontend/editor/src/portal/api/demoData.ts
new file mode 100644
index 0000000000..7469bdae75
--- /dev/null
+++ b/frontend/editor/src/portal/api/demoData.ts
@@ -0,0 +1,54 @@
+/**
+ * Demo-data seam: while enabled, apiClient answers from the portal's MSW
+ * fixture handlers instead of the network. There is no service worker and no
+ * request interception — only fetches made through @portal/api/http see
+ * fixture data, and only while the flag is on. msw and the handlers/fixtures
+ * chunk are loaded on first enable, so ordinary sessions never pay for them.
+ *
+ * Built for the portal onboarding tour: enable on tour start so every view the
+ * tour visits renders populated, disable on finish/skip — views refetch real
+ * data when they next mount.
+ */
+import type { HttpRequestOptions } from "@portal/api/http";
+
+type DemoResolver = (request: Request) => Promise;
+
+let resolver: DemoResolver | null = null;
+let active = false;
+
+/** Turn demo data on. Safe to call repeatedly; loads msw + fixtures once. */
+export async function enablePortalDemoData(): Promise {
+ if (!resolver) {
+ const [{ getResponse }, { handlers }] = await Promise.all([
+ import("msw"),
+ import("@portal/mocks/handlers"),
+ ]);
+ resolver = (request) => getResponse(handlers, request);
+ }
+ active = true;
+}
+
+/** Turn demo data off. Views pick up real data on their next fetch. */
+export function disablePortalDemoData(): void {
+ active = false;
+}
+
+export function isPortalDemoDataActive(): boolean {
+ return active;
+}
+
+/**
+ * Fixture response for the request while demo data is on; undefined when demo
+ * data is off or no handler matches (callers then hit the real network).
+ */
+export async function resolveDemoResponse(
+ url: URL,
+ options: HttpRequestOptions,
+): Promise {
+ if (!active || !resolver) return undefined;
+ const request = new Request(url, {
+ method: options.method ?? "GET",
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
+ });
+ return resolver(request);
+}
diff --git a/frontend/editor/src/portal/api/docs.ts b/frontend/editor/src/portal/api/docs.ts
index 0de1911d4f..b662b4f177 100644
--- a/frontend/editor/src/portal/api/docs.ts
+++ b/frontend/editor/src/portal/api/docs.ts
@@ -1,20 +1,116 @@
import { apiClient } from "@portal/api/http";
+import type { CardAccent, CodeLang } from "@app/ui";
import type { Tier } from "@portal/contexts/TierContext";
-import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
-export type {
- AgentSkill,
- ApiErrorRow,
- CodeSample,
- DocsContent,
- DocsNavItem,
- DocsNavSection,
- EmbedComponent,
- Playbook,
- RateLimit,
- Sdk,
- SdkStatus,
-} from "@portal/mocks/docs";
+/*
+ * Developer Docs. Two payloads back the surface: the left-hand nav tree and
+ * the data-driven reference content — code samples, SDK matrix, embeddable
+ * components, playbooks, agent skills, the error table, and the tier-scaled
+ * rate-limit grid.
+ */
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Navigation */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/** A leaf entry in the docs nav — maps 1:1 to a content section. */
+export interface DocsNavItem {
+ /** Stable id used as the in-page section anchor. */
+ id: string;
+ label: string;
+ /** Optional badge shown to the right of the label (e.g. "New", "Beta"). */
+ badge?: string;
+}
+
+/** A top-level grouping in the docs nav tree. */
+export interface DocsNavSection {
+ id: string;
+ label: string;
+ /** Single-glyph icon shown beside the section header. */
+ icon: string;
+ items: DocsNavItem[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Reference content */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/** One tab in a multi-language code snippet. */
+export interface CodeSample {
+ /** Stable key used as the snippet tab id. */
+ key: string;
+ label: string;
+ lang: CodeLang;
+ code: string;
+}
+
+/** Per-tier request ceilings rendered by the rate-limits section. */
+export interface RateLimit {
+ rpm: string;
+ burst: string;
+ concurrency: string;
+}
+
+/** A single HTTP status row in the error table. */
+export interface ApiErrorRow {
+ code: string;
+ /** Severity colour — amber for recoverable, red for hard failures. */
+ tone: "amber" | "red";
+ meaning: string;
+}
+
+export type SdkStatus = "ga" | "beta" | "deprecated";
+
+/** An official client library in the SDK matrix. */
+export interface Sdk {
+ name: string;
+ /** Single-glyph icon shown beside the name. */
+ icon: string;
+ install: string;
+ lang: CodeLang;
+ status: SdkStatus;
+}
+
+/** An embeddable UI component in the drop-in viewer library. */
+export interface EmbedComponent {
+ name: string;
+ blurb: string;
+ /** Stack tag, e.g. "React" or "Web". */
+ tag: string;
+}
+
+/** A copy-paste, end-to-end pipeline recipe. */
+export interface Playbook {
+ title: string;
+ blurb: string;
+ /** Ordered stages rendered as a chip flow. */
+ steps: string[];
+ accent: CardAccent;
+}
+
+/** A bundled, named agent capability — a deterministic op chain. */
+export interface AgentSkill {
+ name: string;
+ blurb: string;
+ /** Op chain shown as a mono string, e.g. "extract · validate". */
+ ops: string;
+}
+
+/** The complete data-driven docs payload for one tier. */
+export interface DocsContent {
+ quickstartSamples: CodeSample[];
+ quickstartResponse: string;
+ rateLimit: RateLimit;
+ errors: ApiErrorRow[];
+ sdks: Sdk[];
+ components: EmbedComponent[];
+ playbooks: Playbook[];
+ skills: AgentSkill[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Endpoints */
+/* ──────────────────────────────────────────────────────────────────────── */
/** GET /v1/docs/nav — the docs nav tree. */
export async function fetchDocsNav(): Promise {
diff --git a/frontend/editor/src/portal/api/documents.ts b/frontend/editor/src/portal/api/documents.ts
index 25e34f731d..cbb109ec30 100644
--- a/frontend/editor/src/portal/api/documents.ts
+++ b/frontend/editor/src/portal/api/documents.ts
@@ -1,25 +1,132 @@
import { apiClient } from "@portal/api/http";
-import type { DocumentsResponse } from "@portal/mocks/documents";
+import type { StatusTone, ChipAccent } from "@app/ui";
import type { Tier } from "@portal/contexts/TierContext";
-export type {
- DocAuditEvent,
- DocAuditKind,
- DocumentStatus,
- DocumentsResponse,
- DocumentsSummary,
- Extraction,
- ProductType,
- ReviewDocument,
-} from "@portal/mocks/documents";
-export {
- classificationTone,
- DOC_AUDIT_LABEL,
- DOC_AUDIT_TONE,
- DOCUMENT_STATUS_LABEL,
- DOCUMENT_STATUS_TONE,
- PRODUCT_CHIP_TONE,
-} from "@portal/mocks/documents";
+export type DocumentStatus = "processed" | "flagged" | "in-review" | "error";
+
+/** Which Stirling product ran the operation. */
+export type ProductType = "API" | "Editor";
+
+/** A single field pulled out of the document by extraction. */
+export interface Extraction {
+ field: string;
+ value: string;
+ confidence: number;
+}
+
+export type DocAuditKind =
+ | "ingested"
+ | "extracted"
+ | "flagged"
+ | "reviewed"
+ | "approved"
+ | "archived"
+ | "elevation";
+
+/** One event in a document's lifecycle, newest last. */
+export interface DocAuditEvent {
+ id: string;
+ kind: DocAuditKind;
+ time: string;
+ actor: string;
+ detail: string;
+}
+
+export interface ReviewDocument {
+ id: string;
+ name: string;
+ /** File-type label, e.g. "PDF". */
+ type: string;
+ /** Auto-classification label (e.g. "Contract"), or null when not classified. */
+ classification: string | null;
+ /** True when the classification was assigned automatically. */
+ auto: boolean;
+ /** Short descriptive sub-line (editor action or flag reason), or null. */
+ note: string | null;
+ /** Where it was processed. */
+ product: ProductType;
+ /** Pipeline/action, e.g. "contract". Null (or Editor product) renders "Editor". */
+ action: string | null;
+ /** The user who ran it. */
+ user: string;
+ status: DocumentStatus;
+ /** Reviewer name for in-review docs, e.g. "Sarah K.". */
+ reviewer: string | null;
+ /** Originating source name. */
+ source: string;
+ /** Overall confidence 0..1, or null (unsupported - never shown in the table). */
+ confidence: number | null;
+ fieldsExtracted: number;
+ /** Relative-time string, e.g. "2 min ago". */
+ time: string;
+ sensitive: boolean;
+ extractions: Extraction[];
+ audit: DocAuditEvent[];
+}
+
+export interface DocumentsSummary {
+ totalInQueue: number;
+ processed: number;
+ errors: number;
+ processedToday: number;
+}
+
+export interface DocumentsResponse {
+ summary: DocumentsSummary;
+ documents: ReviewDocument[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Presentation metadata (label + chip tone) */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/** Values are i18n keys — render with t(). */
+export const DOCUMENT_STATUS_LABEL: Record = {
+ processed: "portal.documents.status.processed",
+ flagged: "portal.documents.status.flagged",
+ "in-review": "portal.documents.status.inReview",
+ error: "portal.documents.status.error",
+};
+
+export const DOCUMENT_STATUS_TONE: Record = {
+ processed: "success",
+ flagged: "warning",
+ "in-review": "purple",
+ error: "danger",
+};
+
+export const PRODUCT_CHIP_TONE: Record = {
+ API: "brand",
+ Editor: "success",
+};
+
+/** Classification chip accent: danger when unclassified, warning when it needs a look. */
+export function classificationTone(doc: ReviewDocument): ChipAccent {
+ if (doc.classification === "Unclassified") return "danger";
+ if (doc.status === "processed") return "success";
+ return "warning";
+}
+
+/** Values are i18n keys — render with t(). */
+export const DOC_AUDIT_LABEL: Record = {
+ ingested: "portal.documents.audit.ingested",
+ extracted: "portal.documents.audit.extracted",
+ flagged: "portal.documents.audit.flagged",
+ reviewed: "portal.documents.audit.reviewed",
+ approved: "portal.documents.audit.approved",
+ archived: "portal.documents.audit.archived",
+ elevation: "portal.documents.audit.elevation",
+};
+
+export const DOC_AUDIT_TONE: Record = {
+ ingested: "info",
+ extracted: "success",
+ flagged: "warning",
+ reviewed: "purple",
+ approved: "success",
+ archived: "neutral",
+ elevation: "purple",
+};
/** GET the audit-derived Documents feed; SaaS or local, scoped server-side. `tier` ignored. */
export async function fetchDocuments(tier: Tier): Promise {
diff --git a/frontend/editor/src/portal/api/editorDeploy.ts b/frontend/editor/src/portal/api/editorDeploy.ts
index 38517c0bc9..67952d8bb4 100644
--- a/frontend/editor/src/portal/api/editorDeploy.ts
+++ b/frontend/editor/src/portal/api/editorDeploy.ts
@@ -1,25 +1,155 @@
import { apiClient } from "@portal/api/http";
-import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
import type { Tier } from "@portal/contexts/TierContext";
-export type {
- DeploymentTarget,
- DeploymentSummary,
- DeploymentSummaryMetric,
- EditorDeploymentResponse,
- EditorInstance,
+/*
+ * This surface manages the org's deployment of the Stirling PDF *Editor*
+ * product from the portal — where it runs (Managed Cloud / Docker /
+ * Kubernetes), how self-hosted instances pair back to the org, the health of
+ * each running instance, and the service credential / offline-activation
+ * lifecycle.
+ */
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Deployment targets */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/** Where an Editor deployment can run. */
+export type TargetKind = "cloud" | "docker" | "kubernetes";
+
+/**
+ * Whether a target is usable on the current tier and, if so, whether the org
+ * has actually stood it up. `locked` targets render an upgrade nudge instead of
+ * a runnable snippet.
+ */
+export type TargetState = "running" | "available" | "locked";
+
+export interface DeploymentTarget {
+ kind: TargetKind;
+ label: string;
+ /** One-line positioning shown under the title. */
+ tagline: string;
+ state: TargetState;
+ /** Minimum tier that unlocks this target — drives the upgrade nudge copy. */
+ requiresTier: Tier;
+ /** Install / run snippet for the target's CodeBlock. */
+ snippet: string;
+ /** Language hint for the CodeBlock chrome. */
+ snippetLang: "bash" | "plain";
+ /** Populated only when `state === "running"`. */
+ runningVersion?: string;
+ /** Count of instances currently reporting in for this target. */
+ instanceCount?: number;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Pairing */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/** How a self-hosted editor connects itself to the org. */
+export type PairingMethod = "token" | "shortcode" | "iac";
+
+export interface PairingOption {
+ method: PairingMethod;
+ label: string;
+ description: string;
+ /** Minimum tier that unlocks this method. */
+ requiresTier: Tier;
+ /**
+ * The current secret/handle to display. A long-lived pairing token, a
+ * TV-style short code, or an IaC reference (e.g. a Terraform module input).
+ * Pre-masked for token display — never carries the real secret.
+ */
+ value: string;
+ /** Short codes expire fast; tokens rotate on demand. Relative-time string. */
+ expires?: string;
+ /** Whether this option is currently usable on the active tier. */
+ locked: boolean;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Running instances (deployment health) */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type InstanceStatus = "healthy" | "degraded" | "offline" | "pairing";
+
+export interface EditorInstance {
+ id: string;
+ /** Human host label, e.g. "edge-fra-01" or "Managed Cloud (us-east-1)". */
+ host: string;
+ target: TargetKind;
+ version: string;
+ region: string;
+ status: InstanceStatus;
+ /** Relative-time string, e.g. "12s ago". */
+ lastSeen: string;
+ activeUsers: number;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Summary metric strip */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export interface DeploymentSummaryMetric {
+ label: string;
+ value: string | number;
+ delta?: number;
+ deltaDirection?: "up" | "down" | "flat";
+ description?: string;
+}
+
+export interface DeploymentSummary {
+ metrics: DeploymentSummaryMetric[];
+ /** Masked service token + its rotation age, shown by the rotation card. */
+ serviceToken: { masked: string; lastRotated: string };
+ /** Air-gapped activation is enterprise-only; gate the card on this flag. */
+ offlineActivationAvailable: boolean;
+ /** Where users launch the Editor — the org workspace URL (Open in browser). */
+ workspaceUrl: string;
+}
+
+export interface EditorDeploymentResponse {
+ summary: DeploymentSummary;
+ targets: DeploymentTarget[];
+ pairings: PairingOption[];
+ instances: EditorInstance[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Presentation metadata (lives client-side — product copy, not data) */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export interface TargetMeta {
+ icon: string;
+ tone: "neutral" | "blue" | "purple";
+}
+
+export const TARGET_META: Record = {
+ cloud: { icon: "☁", tone: "blue" },
+ docker: { icon: "▣", tone: "neutral" },
+ kubernetes: { icon: "⎈", tone: "purple" },
+};
+
+export const INSTANCE_STATUS_TONE: Record<
InstanceStatus,
- PairingMethod,
- PairingOption,
- TargetKind,
- TargetMeta,
- TargetState,
-} from "@portal/mocks/editorDeploy";
-export {
- INSTANCE_STATUS_LABEL,
- INSTANCE_STATUS_TONE,
- TARGET_META,
-} from "@portal/mocks/editorDeploy";
+ "success" | "warning" | "danger" | "info" | "neutral"
+> = {
+ healthy: "success",
+ degraded: "warning",
+ offline: "danger",
+ pairing: "info",
+};
+
+/** Values are i18n keys — render with t(). */
+export const INSTANCE_STATUS_LABEL: Record = {
+ healthy: "portal.editorAdmin.status.healthy",
+ degraded: "portal.editorAdmin.status.degraded",
+ offline: "portal.editorAdmin.status.offline",
+ pairing: "portal.editorAdmin.status.pairing",
+};
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Endpoints */
+/* ──────────────────────────────────────────────────────────────────────── */
/**
* GET /v1/editor/deployment?tier=… — the org's Editor deployment: summary
diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts
index b479a226c7..5c8afcd2cb 100644
--- a/frontend/editor/src/portal/api/http.ts
+++ b/frontend/editor/src/portal/api/http.ts
@@ -42,6 +42,7 @@
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
*/
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
+import { resolveDemoResponse } from "@portal/api/demoData";
import { saasApiBase } from "@portal/api/saasApiBase";
import {
localAuthHeader,
@@ -148,6 +149,11 @@ async function localJson(
path: string,
options: HttpRequestOptions = {},
): Promise {
+ const demo = await resolveDemoResponse(
+ new URL(`${localBaseUrl()}${path}`, window.location.origin),
+ options,
+ );
+ if (demo) return unwrap(demo);
const res = await fetch(`${localBaseUrl()}${path}`, {
method: options.method ?? "GET",
headers: {
@@ -214,6 +220,14 @@ async function saasJson(
path: string,
options: HttpRequestOptions = {},
): Promise {
+ // Resolved before the config/session gates so demo data works on an
+ // unlinked or unconfigured org. http://saas.mock is the origin the SaaS
+ // handlers are written against (same one Storybook injects).
+ const demo = await resolveDemoResponse(
+ new URL(path, "http://saas.mock"),
+ options,
+ );
+ if (demo) return unwrap(demo);
const base = saasBaseUrl();
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
if (base === null) throw new SaasUnconfiguredError();
diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts
index ec844330fe..073409e6c6 100644
--- a/frontend/editor/src/portal/api/infrastructure.ts
+++ b/frontend/editor/src/portal/api/infrastructure.ts
@@ -1,50 +1,264 @@
import { apiClient } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
-import type {
- ApiKey,
- AuditLogResponse,
- DeploymentRegion,
- ModelsResponse,
- RecentDeployment,
- SecurityConfig,
- StorageConfig,
-} from "@portal/mocks/infrastructure";
-export type {
- AccessPolicy,
- ApiKey,
- ApiKeyPermission,
- ApiKeyStatus,
- AttestationStatus,
- AuditCategory,
- AuditEvent,
- AuditLogResponse,
- AuditStatus,
- AuditSummary,
- CertStatus,
- ComplianceAttestation,
- ComplianceCert,
- DataResidency,
- DeploymentRegion,
- DeploymentStatus,
- IpAllowEntry,
- KeyManagement,
- KeyMode,
- ModelCostUnit,
- ModelEntry,
- ModelProvider,
- ModelsResponse,
- ModelsSummary,
- ModelStatus,
- ModelType,
- RecentDeployment,
- RegionStatus,
- RetentionWindow,
- RoutingRule,
- SecurityConfig,
- StorageConfig,
- StorageProvider,
-} from "@portal/mocks/infrastructure";
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Deployments */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type RegionStatus = "healthy" | "degraded" | "down";
+
+export interface DeploymentRegion {
+ name: string;
+ code: string;
+ /** Median request latency, ms. */
+ latencyMs: number;
+ /** Current load as a fraction of provisioned capacity (0–1). */
+ load: number;
+ status: RegionStatus;
+ /** Deployed Stirling engine version. */
+ version: string;
+ /** 30-day uptime as a fraction (0–1). */
+ uptime: number;
+ /** Running instance count. */
+ instances: number;
+ /** Sustained throughput, docs/min. */
+ throughput: number;
+ /** P99 latency, ms. */
+ p99Ms: number;
+}
+
+export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued";
+
+export interface RecentDeployment {
+ id: string;
+ version: string;
+ environment: "production" | "staging" | "canary";
+ product: string;
+ status: DeploymentStatus;
+ deployedBy: string;
+ timestamp: string;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* API Keys */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
+export type ApiKeyPermission = "Read" | "Write" | "Admin";
+
+export interface ApiKey {
+ id: string;
+ name: string;
+ /** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
+ prefix: string;
+ created: string;
+ lastUsed: string;
+ status: ApiKeyStatus;
+ /** Requests/min ceiling. */
+ rateLimit: number;
+ permissions: ApiKeyPermission[];
+ allowedIps: string[];
+ usageToday: number;
+ usageMonth: number;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Security */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type AccessPolicy = "stirling" | "byok" | "hyok";
+export type DataResidency = "us" | "eu" | "apac";
+export type CertStatus = "certified" | "in-progress" | "not-started";
+
+export interface ComplianceCert {
+ id: string;
+ name: string;
+ status: CertStatus;
+ detail: string;
+}
+
+export interface IpAllowEntry {
+ id: string;
+ label: string;
+ cidr: string;
+ addedBy: string;
+ added: string;
+}
+
+/**
+ * Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is
+ * surfaced separately because the key *custody model* (who can decrypt) is the
+ * detail security teams scrutinise:
+ * - `managed` — Stirling-owned KMS keys; zero key ops on the customer side.
+ * - `byok` — customer key, but Stirling can use it to decrypt while processing.
+ * - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext.
+ */
+export type KeyMode = "managed" | "byok" | "hyok";
+
+export interface KeyManagement {
+ mode: KeyMode;
+ /** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */
+ provider: string;
+ /** ARN-style identifier for the active key. */
+ keyId: string;
+ /** Encryption algorithm in force. */
+ algorithm: string;
+ /** Relative last-rotation time, e.g. "32 days ago". */
+ lastRotated: string;
+ /** Rotation cadence summary, e.g. "Automatic · every 90 days". */
+ rotationPolicy: string;
+ /**
+ * Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed
+ * tiers see the posture but cannot change provider — only enterprise can.
+ */
+ customerManaged: boolean;
+}
+
+export type AttestationStatus = "attested" | "in-scope" | "not-applicable";
+
+export interface ComplianceAttestation {
+ id: string;
+ name: string;
+ /** Framework family / short descriptor shown under the name. */
+ framework: string;
+ status: AttestationStatus;
+ /** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */
+ detail: string;
+ /** Stub link to the downloadable report; null when none is available. */
+ reportUrl: string | null;
+}
+
+export interface SecurityConfig {
+ accessPolicy: AccessPolicy;
+ dataResidency: DataResidency;
+ certs: ComplianceCert[];
+ ipAllowlist: IpAllowEntry[];
+ keyManagement: KeyManagement;
+ attestations: ComplianceAttestation[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Storage */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type RetentionWindow = "30" | "60" | "90" | "180" | "never";
+
+export interface StorageProvider {
+ id: string;
+ name: string;
+ kind: "stirling" | "s3" | "azure";
+ connected: boolean;
+ detail: string;
+ usedGb: number;
+}
+
+export interface StorageConfig {
+ /** Total used storage, GB. */
+ usedGb: number;
+ /** Quota ceiling, GB. */
+ quotaGb: number;
+ retention: RetentionWindow;
+ providers: StorageProvider[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Audit Logs */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type AuditCategory =
+ | "auth"
+ | "config"
+ | "elevation"
+ | "processing"
+ | "security";
+
+export type AuditStatus = "success" | "warning" | "danger" | "info";
+
+export interface AuditEvent {
+ id: string;
+ timestamp: string;
+ category: AuditCategory;
+ action: string;
+ actor: string;
+ target: string;
+ status: AuditStatus;
+ latencyMs: number;
+}
+
+export interface AuditSummary {
+ totalEvents: number;
+ processing: number;
+ elevation: number;
+ config: number;
+}
+
+export interface AuditLogResponse {
+ summary: AuditSummary;
+ events: AuditEvent[];
+ /** True for the whole-server (admin) view; gates the admin-only CSV export. */
+ fullServer: boolean;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Models */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem";
+export type ModelType = "extraction" | "classification" | "ocr" | "llm";
+export type ModelStatus = "active" | "degraded" | "disabled";
+
+/** Whether a model's cost is billed per 1k documents or per individual call. */
+export type ModelCostUnit = "per-1k-docs" | "per-call";
+
+export interface ModelEntry {
+ id: string;
+ name: string;
+ provider: ModelProvider;
+ type: ModelType;
+ status: ModelStatus;
+ /** Median inference latency, ms. */
+ latencyMs: number;
+ /** Cost in USD for the model's billing unit (see {@link costUnit}). */
+ cost: number;
+ costUnit: ModelCostUnit;
+ version: string;
+ /** Share of capacity this model is currently absorbing (0–1). */
+ load: number;
+ /** True for customer-registered bring-your-own / on-prem models. */
+ managed: boolean;
+}
+
+/** A binding from a processing operation (optionally a doc-type) to a model. */
+export interface RoutingRule {
+ id: string;
+ /** The operation or pipeline stage this rule governs. */
+ operation: string;
+ /** Doc-type scope, or "All document types" for a catch-all. */
+ docType: string;
+ /** id of the {@link ModelEntry} this operation routes to. */
+ modelId: string;
+ modelName: string;
+ /** Marks the fallback rule applied when no narrower rule matches. */
+ isDefault: boolean;
+}
+
+export interface ModelsSummary {
+ activeModels: number;
+ /** Capacity-weighted average latency across active models, ms. */
+ avgLatencyMs: number;
+ /** Projected monthly model spend, USD. */
+ monthlySpend: number;
+}
+
+export interface ModelsResponse {
+ summary: ModelsSummary;
+ models: ModelEntry[];
+ routing: RoutingRule[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Endpoints */
+/* ──────────────────────────────────────────────────────────────────────── */
export interface DeploymentsResponse {
regions: DeploymentRegion[];
diff --git a/frontend/editor/src/portal/api/link.ts b/frontend/editor/src/portal/api/link.ts
index 9b4876ac8d..696ccbe5b2 100644
--- a/frontend/editor/src/portal/api/link.ts
+++ b/frontend/editor/src/portal/api/link.ts
@@ -1,17 +1,46 @@
import { apiClient } from "@portal/api/http";
-import type {
- LinkInstanceRequest,
- LinkStatus,
- LinkedInstanceRow,
- LocalUsage,
-} from "@portal/mocks/link";
-export type {
- LinkInstanceRequest,
- LinkStatus,
- LinkedInstanceRow,
- LocalUsage,
-} from "@portal/mocks/link";
+/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */
+export interface LinkInstanceRequest {
+ /** Admin's SaaS session JWT, obtained via the hosted-login popup. */
+ supabaseJwt: string;
+ /** Optional label for this instance. */
+ name?: string;
+}
+
+/** Link status for this instance (GET /api/v1/account-link/status). */
+export interface LinkStatus {
+ linked: boolean;
+ /** Display name the local backend stored at link time; null when unset. */
+ name: string | null;
+}
+
+/**
+ * Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage).
+ * The portal adds this on top of the SaaS-synced spend so "current usage"
+ * includes work done since the last daily sync. Per-category unsynced units for
+ * the current period; all zero when metering is off or nothing is pending.
+ */
+export interface LocalUsage {
+ /** ISO timestamp of the current period start; null when unknown (not yet synced). */
+ periodStart: string | null;
+ apiUnsyncedUnits: number;
+ aiUnsyncedUnits: number;
+ automationUnsyncedUnits: number;
+ totalUnsyncedUnits: number;
+}
+
+/** A linked instance row (GET /api/v1/account-link/instances). */
+export interface LinkedInstanceRow {
+ instanceId: number;
+ deviceId: string;
+ name: string | null;
+ /** ISO timestamp the instance was registered. */
+ createdAt: string | null;
+ /** ISO timestamp the instance last presented its credential; null if never. */
+ lastSeenAt: string | null;
+ revoked: boolean;
+}
/**
* Account-link client (combined-billing "Mode A"). Two distinct surfaces:
@@ -33,9 +62,9 @@ export type {
* - POST /api/v1/account-link/instances/{id}/revoke
*
* The team-wide endpoints are served by the hosted SaaS Java backend (the
- * local backend has no such routes), so they go through apiClient.saas. They're
- * MSW-intercepted in dev/Storybook via wildcard handlers that match both the
- * local and absolute SaaS URLs.
+ * local backend has no such routes), so they go through apiClient.saas. In
+ * Storybook/tests, wildcard MSW handlers match both the local and absolute
+ * SaaS URLs.
*/
const BASE = "/api/v1/account-link";
diff --git a/frontend/editor/src/portal/api/notifications.ts b/frontend/editor/src/portal/api/notifications.ts
index ac0b2f85bc..5716d9f7bb 100644
--- a/frontend/editor/src/portal/api/notifications.ts
+++ b/frontend/editor/src/portal/api/notifications.ts
@@ -1,10 +1,21 @@
import { apiClient } from "@portal/api/http";
-import type {
- Notification,
- NotificationCategory,
-} from "@portal/mocks/notifications";
-export type { Notification, NotificationCategory };
+export type NotificationCategory =
+ | "pipeline"
+ | "deploy"
+ | "billing"
+ | "audit"
+ | "agent"
+ | "doc";
+
+export interface Notification {
+ id: string;
+ category: NotificationCategory;
+ title: string;
+ description: string;
+ /** Relative-time string. */
+ time: string;
+}
/** GET /v1/notifications */
export async function fetchNotifications(): Promise {
diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts
index 20f26fccc6..2fd5b2916e 100644
--- a/frontend/editor/src/portal/api/pipelines.ts
+++ b/frontend/editor/src/portal/api/pipelines.ts
@@ -6,9 +6,8 @@ import { apiClient } from "@portal/api/http";
* A "pipeline" in the portal IS a backend policy (PolicyController, Policy.java):
* an ordered chain of tool steps with input sources, a trigger, and an output
* destination. This surface lists EVERY backend policy (the user-facing Policies
- * page builds only a friendly subset of the same records). Like Sources, it calls
- * the REAL Stirling API base `/api/v1/policies`, so dropping MSW points these exact
- * calls at the live backend.
+ * page builds only a friendly subset of the same records). Like Sources, it
+ * calls the real Stirling API base `/api/v1/policies`.
*/
/** One tool invocation in a pipeline. `operation` is a Stirling endpoint path. */
diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts
index a28b5d2889..7954e04c59 100644
--- a/frontend/editor/src/portal/api/policies.ts
+++ b/frontend/editor/src/portal/api/policies.ts
@@ -1,9 +1,8 @@
/**
* Policies service layer.
*
- * The portal calls the real Stirling policy API (`/api/v1/policies`). MSW
- * intercepts these calls in dev/Storybook; dropping MSW is enough to hit the
- * live backend — no call-site changes needed.
+ * The portal calls the real Stirling policy API (`/api/v1/policies`);
+ * Storybook and tests intercept the same calls with MSW handlers.
*
* `fetchPolicies()` assembles the decorated catalogue client-side from the
* backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same
@@ -13,53 +12,404 @@
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
-import type { PolicyDecodedState, WirePolicy } from "@app/policies/types";
-import {
- POLICY_CATEGORIES,
- POLICY_CONFIG,
- type CatalogueEntry,
- type DecoratedPolicy,
- type PoliciesResponse,
- type PoliciesSummary,
- type PolicySetupResult,
- type PolicyState,
- type PolicyStatus,
-} from "@portal/mocks/policies";
-import type { PolicyRunView } from "@app/policies/types";
+import type {
+ PolicyDecodedState,
+ PolicyRunView,
+ WirePipelineStep,
+ WirePolicy,
+} from "@app/policies/types";
export type {
- CatalogueEntry,
- DecoratedPolicy,
- PoliciesResponse,
- PoliciesSummary,
- PolicyCategory,
- PolicyConfigDef,
- PolicyDecodedState,
- PolicyField,
- PolicyFieldType,
- PolicyRowStatus,
- PolicyRunView,
- PolicySetupResult,
- PolicyState,
- PolicyStats,
PolicyActivityItem,
- PolicyStatus,
- WirePolicy,
+ PolicyDecodedState,
+ PolicyRunView,
+ PolicyStats,
WireOutputOptions,
WireOutputSpec,
-} from "@portal/mocks/policies";
-export {
- ENDPOINT_LABELS,
- POLICY_CATEGORIES,
- POLICY_CONFIG,
- POLICY_DOC_TYPES,
- TOOL_ENDPOINTS,
- humanizeEndpoint,
-} from "@portal/mocks/policies";
+ WirePolicy,
+} from "@app/policies/types";
// Re-export the wire step type under the legacy name components depend on.
export type { WirePipelineStep as PipelineStep } from "@app/policies/types";
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Catalogue model — portal-specific */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type PolicyStatus = "active" | "paused";
+
+export type PolicyRowStatus = "active" | "paused" | "setup";
+
+export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
+
+export interface PolicyField {
+ label: string;
+ key: string;
+ type: PolicyFieldType;
+ value: boolean | string | string[];
+ options?: string[];
+}
+
+export interface PolicyCategory {
+ id: string;
+ label: string;
+ icon: string;
+ tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
+ desc: string;
+ providesClassification?: boolean;
+ comingSoon?: boolean;
+}
+
+export interface PolicyConfigDef {
+ summary: string;
+ rules: string[];
+ scopeLabel: string;
+ fields: PolicyField[];
+ defaultOperations: WirePipelineStep[];
+}
+
+export interface PolicyState {
+ configured: boolean;
+ status: PolicyStatus;
+ sources: string[];
+ scopeTypes: string[];
+ reviewerEmail: string;
+ fieldValues: Record;
+ outputMode?: "new_file" | "new_version";
+ outputName?: string;
+ outputNamePosition?: "prefix" | "suffix" | "auto-number";
+ runOn?: "upload" | "export";
+ maxRetries?: number;
+ retryDelayMinutes?: number;
+ backendId?: string;
+ isDefault?: boolean;
+}
+
+export interface PolicySetupResult {
+ fieldValues: Record;
+ sources: string[];
+ scopeTypes: string[];
+ reviewerEmail: string;
+ outputMode: "new_file" | "new_version";
+ outputName: string;
+ outputNamePosition: "prefix" | "suffix" | "auto-number";
+ runOn: "upload" | "export";
+ maxRetries: number;
+ retryDelayMinutes: number;
+ steps: WirePipelineStep[];
+}
+
+export interface DecoratedPolicy {
+ category: PolicyCategory;
+ config: PolicyConfigDef;
+ state: PolicyState;
+ steps: WirePipelineStep[];
+ stats: import("@app/policies/types").PolicyStats;
+ activity: import("@app/policies/types").PolicyActivityItem[];
+}
+
+export interface PoliciesSummary {
+ active: number;
+ paused: number;
+ categories: number;
+ docsEnforced: number;
+}
+
+export interface PoliciesResponse {
+ summary: PoliciesSummary;
+ catalogue: CatalogueEntry[];
+}
+
+export interface CatalogueEntry {
+ category: PolicyCategory;
+ config: PolicyConfigDef;
+ policy: DecoratedPolicy | null;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Tool → endpoint registry */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const TOOL_ENDPOINTS: Record = {
+ redact: "/api/v1/security/auto-redact",
+ sanitize: "/api/v1/security/sanitize-pdf",
+ watermark: "/api/v1/security/add-watermark",
+ ocr: "/api/v1/misc/ocr-pdf",
+ flatten: "/api/v1/misc/flatten",
+ compress: "/api/v1/misc/compress-pdf",
+};
+
+/** Values are i18n keys — render with t(). */
+export const ENDPOINT_LABELS: Record = {
+ "/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
+ "/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
+ "/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
+ "/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf",
+ "/api/v1/misc/flatten": "portal.policies.endpoints.flatten",
+ "/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
+};
+
+export function humanizeEndpoint(
+ path: string,
+ t: (key: string) => string,
+): string {
+ if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
+ const last = path.split("/").filter(Boolean).pop() ?? path;
+ return last
+ .replace(/-/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ .trim();
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Catalogue definitions */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+const DEFAULT_PII_PATTERNS: string[] = [
+ "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
+ "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b",
+];
+
+/** `label`/`desc` values are i18n keys — render with t(). */
+export const POLICY_CATEGORIES: PolicyCategory[] = [
+ {
+ id: "ingestion",
+ label: "portal.policies.categories.ingestion.label",
+ icon: "layers",
+ tone: "blue",
+ desc: "portal.policies.categories.ingestion.desc",
+ providesClassification: true,
+ comingSoon: true,
+ },
+ {
+ id: "security",
+ label: "portal.policies.categories.security.label",
+ icon: "shield",
+ tone: "purple",
+ desc: "portal.policies.categories.security.desc",
+ },
+ {
+ id: "compliance",
+ label: "portal.policies.categories.compliance.label",
+ icon: "check",
+ tone: "amber",
+ desc: "portal.policies.categories.compliance.desc",
+ comingSoon: true,
+ },
+ {
+ id: "routing",
+ label: "portal.policies.categories.routing.label",
+ icon: "route",
+ tone: "green",
+ desc: "portal.policies.categories.routing.desc",
+ comingSoon: true,
+ },
+ {
+ id: "retention",
+ label: "portal.policies.categories.retention.label",
+ icon: "clock",
+ tone: "neutral",
+ desc: "portal.policies.categories.retention.desc",
+ comingSoon: true,
+ },
+];
+
+/**
+ * `summary`/`rules`/`scopeLabel`/field `label` values are i18n keys — render
+ * with t(). Field `value`/`options` strings are persisted policy state and
+ * stay as stable values (translating them would corrupt saved configs).
+ */
+export const POLICY_CONFIG: Record = {
+ ingestion: {
+ summary: "portal.policies.config.ingestion.summary",
+ rules: [
+ "portal.policies.config.ingestion.rules.0",
+ "portal.policies.config.ingestion.rules.1",
+ "portal.policies.config.ingestion.rules.2",
+ "portal.policies.config.ingestion.rules.3",
+ ],
+ scopeLabel: "portal.policies.config.scopeAll",
+ defaultOperations: [
+ { operation: TOOL_ENDPOINTS.ocr, parameters: {} },
+ { operation: TOOL_ENDPOINTS.flatten, parameters: {} },
+ ],
+ fields: [
+ {
+ label: "portal.policies.config.ingestion.fields.minConfidence",
+ key: "minConfidence",
+ type: "select",
+ value: "80%",
+ options: ["60%", "70%", "80%", "90%", "95%"],
+ },
+ {
+ label: "portal.policies.config.ingestion.fields.belowThreshold",
+ key: "belowThreshold",
+ type: "select",
+ value: "Flag for review",
+ options: ["Flag for review", "Route to bucket", "Hold"],
+ },
+ ],
+ },
+ security: {
+ summary: "portal.policies.config.security.summary",
+ rules: [
+ "portal.policies.config.security.rules.0",
+ "portal.policies.config.security.rules.1",
+ "portal.policies.config.security.rules.2",
+ ],
+ scopeLabel: "portal.policies.config.scopeAll",
+ defaultOperations: [
+ {
+ operation: TOOL_ENDPOINTS.redact,
+ parameters: {
+ mode: "automatic",
+ useRegex: true,
+ convertPDFToImage: true,
+ wordsToRedact: DEFAULT_PII_PATTERNS,
+ },
+ },
+ {
+ operation: TOOL_ENDPOINTS.sanitize,
+ parameters: {
+ removeJavaScript: true,
+ removeEmbeddedFiles: false,
+ removeMetadata: false,
+ removeLinks: false,
+ removeFonts: false,
+ },
+ },
+ {
+ operation: TOOL_ENDPOINTS.watermark,
+ // convertPDFToImage bakes the watermark in so it can't be stripped
+ parameters: {
+ convertPDFToImage: true,
+ },
+ },
+ ],
+ fields: [],
+ },
+ compliance: {
+ summary: "portal.policies.config.compliance.summary",
+ rules: [
+ "portal.policies.config.compliance.rules.0",
+ "portal.policies.config.compliance.rules.1",
+ "portal.policies.config.compliance.rules.2",
+ ],
+ scopeLabel: "portal.policies.config.scopeAll",
+ defaultOperations: [
+ { operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
+ { operation: TOOL_ENDPOINTS.flatten, parameters: {} },
+ ],
+ fields: [
+ {
+ label: "portal.policies.config.compliance.fields.frameworks",
+ key: "frameworks",
+ type: "chips",
+ value: ["HIPAA"],
+ options: ["HIPAA", "GDPR", "SOC 2", "FedRAMP", "PCI DSS", "ISO 27001"],
+ },
+ {
+ label: "portal.policies.config.compliance.fields.onViolation",
+ key: "onViolation",
+ type: "select",
+ value: "Flag for review",
+ options: [
+ "Flag for review",
+ "Block export",
+ "Auto-redact PHI",
+ "Quarantine document",
+ ],
+ },
+ {
+ label: "portal.policies.config.compliance.fields.auditTrail",
+ key: "auditTrail",
+ type: "toggle",
+ value: true,
+ },
+ {
+ label: "portal.policies.config.compliance.fields.accessLog",
+ key: "accessLog",
+ type: "toggle",
+ value: true,
+ },
+ ],
+ },
+ routing: {
+ summary: "portal.policies.config.routing.summary",
+ rules: [
+ "portal.policies.config.routing.rules.0",
+ "portal.policies.config.routing.rules.1",
+ "portal.policies.config.routing.rules.2",
+ ],
+ scopeLabel: "portal.policies.config.scopeAll",
+ defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
+ fields: [
+ {
+ label: "portal.policies.config.routing.fields.destination",
+ key: "destination",
+ type: "select",
+ value: "Documents",
+ options: ["Documents", "S3 bucket", "SharePoint", "Webhook"],
+ },
+ {
+ label: "portal.policies.config.routing.fields.webhookUrl",
+ key: "webhookUrl",
+ type: "text",
+ value: "",
+ },
+ {
+ label: "portal.policies.config.routing.fields.notify",
+ key: "notify",
+ type: "toggle",
+ value: false,
+ },
+ ],
+ },
+ retention: {
+ summary: "portal.policies.config.retention.summary",
+ rules: [
+ "portal.policies.config.retention.rules.0",
+ "portal.policies.config.retention.rules.1",
+ "portal.policies.config.retention.rules.2",
+ ],
+ scopeLabel: "portal.policies.config.scopeAll",
+ defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
+ fields: [
+ {
+ label: "portal.policies.config.retention.fields.keepFor",
+ key: "keepFor",
+ type: "select",
+ value: "7 years",
+ options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"],
+ },
+ {
+ label: "portal.policies.config.retention.fields.archiveAfter",
+ key: "archiveAfter",
+ type: "select",
+ value: "Never",
+ options: ["30 days", "90 days", "1 year", "Never"],
+ },
+ {
+ label: "portal.policies.config.retention.fields.immutableHold",
+ key: "immutableHold",
+ type: "toggle",
+ value: false,
+ },
+ ],
+ },
+};
+
+export const POLICY_DOC_TYPES: string[] = [
+ "Contracts",
+ "Invoices",
+ "Tax documents",
+ "HR records",
+ "Insurance",
+ "Medical / PHI",
+ "Legal filings",
+ "Financial reports",
+];
+
// ── Client-side catalogue assembly ───────────────────────────────────────────
function decoratePolicy(
diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts
index 05a903a8af..26d73e5814 100644
--- a/frontend/editor/src/portal/api/procurement.ts
+++ b/frontend/editor/src/portal/api/procurement.ts
@@ -1,28 +1,189 @@
import { apiClient } from "@portal/api/http";
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import type { Tier } from "@portal/contexts/TierContext";
-import type {
- DealStage,
- DocAction,
- ProcurementResponse,
-} from "@portal/mocks/procurement";
-export type {
- Deal,
- DealStage,
- DocAction,
- DocStatus,
- JourneyStep,
- LedgerDoc,
- LedgerGroup,
- ProcurementResponse,
- QuoteInfo,
- SolutionsEngineer,
- SupportingCategory,
- SupportingGroup,
- TrialInfo,
-} from "@portal/mocks/procurement";
-export { JOURNEY } from "@portal/mocks/procurement";
+/*
+ * Procurement models the enterprise commercial journey, trial → quote →
+ * agreement → payment → implementation, plus the paperwork ledger that rides
+ * alongside it. The journey is enterprise-only; free/pro tiers receive a
+ * minimal locked payload the view renders as an upgrade prompt.
+ */
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Journey stages */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/**
+ * The five-stage enterprise journey. The id is the contract value the backend
+ * advances; the labels below are the buyer-facing stage names (Agreement and
+ * Payment read more plainly than the internal `security` / `procurement`).
+ */
+export type DealStage =
+ | "trial"
+ | "quote"
+ | "security"
+ | "procurement"
+ | "active";
+
+export interface JourneyStep {
+ stage: DealStage;
+ /** Buyer-facing stage name. */
+ label: string;
+ /** One-line description of what happens at this stage. */
+ blurb: string;
+ /**
+ * Label for the single action that advances this stage. The current stage
+ * surfaces its gating action; `active` is terminal (provisioning).
+ */
+ gatingAction: string;
+}
+
+/** Ordered journey definition, the stepper renders this verbatim. */
+/** `label`/`blurb`/`gatingAction` values are i18n keys — render with t(). */
+export const JOURNEY: JourneyStep[] = [
+ {
+ stage: "trial",
+ label: "portal.procurement.journeySteps.trial.label",
+ blurb: "portal.procurement.journeySteps.trial.blurb",
+ gatingAction: "portal.procurement.journeySteps.trial.gatingAction",
+ },
+ {
+ stage: "quote",
+ label: "portal.procurement.journeySteps.quote.label",
+ blurb: "portal.procurement.journeySteps.quote.blurb",
+ gatingAction: "portal.procurement.journeySteps.quote.gatingAction",
+ },
+ {
+ stage: "security",
+ label: "portal.procurement.journeySteps.agreement.label",
+ blurb: "portal.procurement.journeySteps.agreement.blurb",
+ gatingAction: "portal.procurement.journeySteps.agreement.gatingAction",
+ },
+ {
+ stage: "procurement",
+ label: "portal.procurement.journeySteps.payment.label",
+ blurb: "portal.procurement.journeySteps.payment.blurb",
+ gatingAction: "portal.procurement.journeySteps.payment.gatingAction",
+ },
+ {
+ stage: "active",
+ label: "portal.procurement.journeySteps.implementation.label",
+ blurb: "portal.procurement.journeySteps.implementation.blurb",
+ gatingAction: "portal.procurement.journeySteps.implementation.gatingAction",
+ },
+];
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Deal header */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export interface SolutionsEngineer {
+ name: string;
+ title: string;
+ email: string;
+}
+
+export interface TrialInfo {
+ /** License key seeded for the evaluation. */
+ key: string;
+ /** ISO date the trial began. */
+ startedOn: string;
+ /** ISO date the trial expires. */
+ endsOn: string;
+ /** Whole days remaining (derived in the fixture for a stable demo number). */
+ daysLeft: number;
+ extensionsUsed: number;
+ maxExtensions: number;
+}
+
+export interface QuoteInfo {
+ number: string;
+ /** Annual contract value, in USD. */
+ amount: number;
+ /** Contract term, e.g. "12 months". */
+ term: string;
+ /** ISO date the quote expires. */
+ validUntil: string;
+}
+
+export interface Deal {
+ company: string;
+ currentStage: DealStage;
+ engineer: SolutionsEngineer;
+ trial: TrialInfo;
+ quote: QuoteInfo;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Document ledger + supporting pool */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/**
+ * Lifecycle of a single document.
+ * available: ready to grab now (download/sign/pay/upload as the action says)
+ * action: waiting on the buyer to act (the gating paperwork of a stage)
+ * pending: issued, awaiting the other side / a system step
+ * request: not generated yet; the buyer asks for it (some carry a fee)
+ * complete: done, kept for the record
+ */
+export type DocStatus =
+ | "available"
+ | "action"
+ | "pending"
+ | "request"
+ | "complete";
+
+/** What pressing the document's button does. */
+export type DocAction = "download" | "sign" | "pay" | "upload" | "request";
+
+export interface LedgerDoc {
+ id: string;
+ name: string;
+ /** Sub-line describing what the document is / what it covers. */
+ sub: string;
+ status: DocStatus;
+ action: DocAction;
+ /** Buyer-skippable paperwork (e.g. paid onboarding). */
+ optional?: boolean;
+ /** One-off fee in USD when the document/service is a paid add-on. */
+ fee?: number;
+}
+
+/** Document ledger grouped by the journey stage the paperwork belongs to. */
+export interface LedgerGroup {
+ stage: DealStage;
+ /** Buyer-facing stage name (matches JourneyStep.label). */
+ label: string;
+ docs: LedgerDoc[];
+}
+
+/** Categories the stage-agnostic supporting pool is grouped under. */
+export type SupportingCategory =
+ | "security"
+ | "legal"
+ | "corporate"
+ | "procurement";
+
+export interface SupportingGroup {
+ category: SupportingCategory;
+ label: string;
+ docs: LedgerDoc[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Full procurement payload */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export interface ProcurementResponse {
+ tier: Tier;
+ /** True only for enterprise, gates the whole journey + ledger. */
+ unlocked: boolean;
+ /** Present only when unlocked. */
+ deal: Deal | null;
+ journey: JourneyStep[];
+ ledger: LedgerGroup[];
+ supporting: SupportingGroup[];
+}
/** GET /v1/procurement?tier=…, the deal, journey, ledger and supporting pool. */
export async function fetchProcurement(
diff --git a/frontend/editor/src/portal/api/sdkComponents.ts b/frontend/editor/src/portal/api/sdkComponents.ts
index 3a43f158a3..99b3b547ef 100644
--- a/frontend/editor/src/portal/api/sdkComponents.ts
+++ b/frontend/editor/src/portal/api/sdkComponents.ts
@@ -1,24 +1,134 @@
import { apiClient } from "@portal/api/http";
-import type { ComponentsResponse } from "@portal/mocks/sdkComponents";
import type { Tier } from "@portal/contexts/TierContext";
-export type {
- BillingUnit,
- ComponentMaturity,
- ComponentPricing,
- ComponentProp,
- ComponentsResponse,
- ComponentsSummary,
- Framework,
- MaturityMeta,
- SdkComponent,
-} from "@portal/mocks/sdkComponents";
-export {
- BILLING_UNIT_LABEL,
- MATURITY_META,
- formatPrice,
- isUnlocked,
-} from "@portal/mocks/sdkComponents";
+/*
+ * "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops
+ * into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel —
+ * each metered per action (per render, per review, per signature). Every
+ * component carries its npm package, maturity, supported frameworks, per-action
+ * price, an install/usage snippet, and its key props.
+ */
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Types */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export type ComponentMaturity = "ga" | "beta";
+
+export type Framework = "React" | "Vue" | "Vanilla";
+
+/** The action a component bills against — surfaces in the price unit label. */
+export type BillingUnit =
+ | "render"
+ | "review"
+ | "approval"
+ | "signature"
+ | "check"
+ | "event"
+ | "session";
+
+export interface ComponentProp {
+ name: string;
+ /** TypeScript-ish type expression, shown verbatim in the API table. */
+ type: string;
+ required: boolean;
+ description: string;
+}
+
+export interface ComponentPricing {
+ /** Price per billed action in USD. */
+ pricePerAction: number;
+ unit: BillingUnit;
+ /** Free-tier monthly allowance before metering kicks in; 0 = none. */
+ freeQuota: number;
+}
+
+export interface SdkComponent {
+ id: string;
+ name: string;
+ /** Package suffix — full name is `@stirling/`. */
+ package: string;
+ description: string;
+ maturity: ComponentMaturity;
+ frameworks: Framework[];
+ pricing: ComponentPricing;
+ /** Install command (npm). */
+ install: string;
+ /** Minimal usage snippet shown under the Code tab. */
+ usage: string;
+ props: ComponentProp[];
+ /**
+ * Embeds attributed to this component over the trailing 30 days — drives the
+ * per-card usage line. Zero for never-embedded components.
+ */
+ embeds30d: number;
+ /**
+ * Tier at which the component becomes available. Components above the active
+ * tier render locked with an upgrade nudge. `pro` is the default floor.
+ */
+ minTier: Tier;
+}
+
+export interface ComponentsSummary {
+ /** Count of GA (production-ready) components available to the tier. */
+ gaCount: number;
+ /** Count of Beta components available to the tier. */
+ betaCount: number;
+ /** Total embeds across all components this month. */
+ embedsThisMonth: number;
+ /** Month-to-date spend attributed to component actions, in USD. */
+ spendThisMonth: number;
+}
+
+export interface ComponentsResponse {
+ summary: ComponentsSummary;
+ components: SdkComponent[];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Presentation metadata (client-side product copy, not data) */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export interface MaturityMeta {
+ label: string;
+ tone: "success" | "info";
+}
+
+/** `label` values are i18n keys — render with t(). */
+export const MATURITY_META: Record = {
+ ga: { label: "portal.components.maturity.ga", tone: "success" },
+ beta: { label: "portal.components.maturity.beta", tone: "info" },
+};
+
+/** Values are i18n keys — render with t(). */
+export const BILLING_UNIT_LABEL: Record = {
+ render: "portal.components.billingUnit.render",
+ review: "portal.components.billingUnit.review",
+ approval: "portal.components.billingUnit.approval",
+ signature: "portal.components.billingUnit.signature",
+ check: "portal.components.billingUnit.check",
+ event: "portal.components.billingUnit.event",
+ session: "portal.components.billingUnit.session",
+};
+
+/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */
+export function formatPrice(
+ pricing: ComponentPricing,
+ t: (key: string) => string,
+): string {
+ return `$${pricing.pricePerAction.toFixed(2)} / ${t(BILLING_UNIT_LABEL[pricing.unit])}`;
+}
+
+const TIER_RANK: Record = { free: 0, pro: 1, enterprise: 2 };
+
+/** Whether a component is usable at the given tier (vs locked/upgrade). */
+export function isUnlocked(component: SdkComponent, tier: Tier): boolean {
+ return TIER_RANK[tier] >= TIER_RANK[component.minTier];
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Endpoints */
+/* ──────────────────────────────────────────────────────────────────────── */
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
export async function fetchComponents(tier: Tier): Promise {
diff --git a/frontend/editor/src/portal/api/search.ts b/frontend/editor/src/portal/api/search.ts
index 2f085066c9..d3fc672577 100644
--- a/frontend/editor/src/portal/api/search.ts
+++ b/frontend/editor/src/portal/api/search.ts
@@ -1,7 +1,11 @@
import { apiClient } from "@portal/api/http";
-import type { QuickAction } from "@portal/mocks/search";
-export type { QuickAction };
+export interface QuickAction {
+ group: "Jump to" | "Create" | "Theme";
+ label: string;
+ /** Keyboard hint shown to the right. */
+ hint: string;
+}
/** GET /v1/search/quick-actions */
export async function fetchQuickActions(): Promise {
diff --git a/frontend/editor/src/portal/api/settings.ts b/frontend/editor/src/portal/api/settings.ts
index 1ec1cb45b3..430ea6e14d 100644
--- a/frontend/editor/src/portal/api/settings.ts
+++ b/frontend/editor/src/portal/api/settings.ts
@@ -1,15 +1,83 @@
import { apiClient } from "@portal/api/http";
-import type { SettingsSnapshot } from "@portal/mocks/settings";
import type { Tier } from "@portal/contexts/TierContext";
-export type {
- ActiveSession,
- BetaFeature,
- NotificationDefault,
- RegionOption,
- SecuritySettings,
- SettingsSnapshot,
-} from "@portal/mocks/settings";
+/*
+ * The account + workspace settings surface. The shape is tier-aware: the
+ * workspace plan label, available regions, and data-residency posture differ
+ * by tier, so the modal reflects what each plan can actually configure.
+ */
+
+export interface RegionOption {
+ value: string;
+ label: string;
+ /** Enterprise-only residency regions are gated below higher tiers. */
+ enterpriseOnly?: boolean;
+}
+
+export interface NotificationDefault {
+ id: string;
+ enabled: boolean;
+}
+
+/** A device/browser with an active session, shown under Admin → Security. */
+export interface ActiveSession {
+ id: string;
+ device: string;
+ location: string;
+ lastActive: string;
+ /** The session viewing this modal — can't be revoked from here. */
+ current: boolean;
+}
+
+/**
+ * Org-wide authentication posture. SSO/SCIM are enterprise capabilities; lower
+ * tiers see them as locked rows with an upgrade nudge.
+ */
+export interface SecuritySettings {
+ mfaEnforced: boolean;
+ ssoEnabled: boolean;
+ scimEnabled: boolean;
+ /** Idle timeout before re-auth, in minutes. */
+ sessionTimeoutMins: number;
+ activeSessions: ActiveSession[];
+}
+
+/** An opt-in early-access feature flag. */
+export interface BetaFeature {
+ id: string;
+ label: string;
+ description: string;
+ enabled: boolean;
+ /** Gated to enterprise — rendered locked below it. */
+ enterpriseOnly?: boolean;
+}
+
+/**
+ * Server snapshot of the account + workspace the modal opens onto. Editable
+ * fields seed local form state; `planLabel` / `seats` are read-only context.
+ */
+export interface SettingsSnapshot {
+ profile: {
+ name: string;
+ email: string;
+ role: string;
+ /** Avatar image URL, or null to fall back to initials. */
+ avatarUrl: string | null;
+ };
+ workspace: {
+ name: string;
+ region: string;
+ planLabel: string;
+ seats: { used: number; total: number };
+ };
+ /** Per-category notification toggles, server-default on/off. */
+ notifications: NotificationDefault[];
+ regions: RegionOption[];
+ /** Org-wide authentication + session posture (Admin scope). */
+ security: SecuritySettings;
+ /** Opt-in early-access features (Admin scope). */
+ betaFeatures: BetaFeature[];
+}
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
export async function fetchSettings(tier: Tier): Promise {
diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts
index 0987f659ec..6100999956 100644
--- a/frontend/editor/src/portal/api/users.ts
+++ b/frontend/editor/src/portal/api/users.ts
@@ -1,25 +1,215 @@
import { apiClient } from "@portal/api/http";
-import { ROLES } from "@portal/mocks/users";
-import type { Member, RoleId, UsersResponse } from "@portal/mocks/users";
import type { Tier } from "@portal/contexts/TierContext";
-export type {
- AccessControls,
- Member,
- MemberStatus,
+/** The four org roles, most → least privileged, mapped onto the backend's
+ * authorities + team leadership. Order drives the role select and grid. */
+export type RoleId = "admin" | "team_owner" | "member" | "guest";
+
+export type MemberStatus = "active" | "invited" | "suspended";
+
+/**
+ * Effective portal (processor) access for a member:
+ * admin — implicit, admins always have it
+ * role — implicit via team-owner leadership (default policy)
+ * team — inherited from a PORTAL grant on the member's whole team
+ * granted — explicit per-user PORTAL grant
+ * none — no access
+ */
+export type PortalAccessState = "admin" | "role" | "team" | "granted" | "none";
+
+export const PORTAL_ACCESS_TONE: Record<
PortalAccessState,
- Role,
- RoleId,
- UsersResponse,
- UsersSummary,
-} from "@portal/mocks/users";
-export {
- MEMBER_STATUS_TONE,
- PORTAL_ACCESS_TONE,
- ROLES,
- ROLE_LABEL,
- ROLE_TONE,
-} from "@portal/mocks/users";
+ "success" | "info" | "neutral" | "warning"
+> = {
+ admin: "info",
+ role: "info",
+ team: "info",
+ granted: "success",
+ none: "neutral",
+};
+
+export interface Member {
+ id: string;
+ name: string;
+ email: string;
+ role: RoleId;
+ status: MemberStatus;
+ /** Effective portal access; set by the view from the grant list. */
+ portalAccess?: PortalAccessState;
+ /** Authoritative server-side portal access (roster DTO); drives whether a chip shows at all. */
+ canAccessPortal?: boolean;
+ /** The explicit PORTAL grant's id, for revoke (present when access = granted). */
+ portalGrantId?: number;
+ /** Relative-time string, e.g. "4m ago". Invited members read "—". */
+ lastActive: string;
+ /** Optional avatar image; falls back to initials when absent. */
+ avatarUrl?: string;
+ /** Backend linkage for row actions (absent on pure fixtures). */
+ username?: string;
+ teamId?: number;
+ teamName?: string;
+ /** Holds a LEADER membership on their team (independent of displayed role). */
+ teamLead?: boolean;
+ /** The signed-in admin's own row; self-directed actions are disabled. */
+ isSelf?: boolean;
+ /** Account locked after failed logins (admin can unlock). */
+ locked?: boolean;
+ /** MFA enrolled (admin can reset it). */
+ mfaEnabled?: boolean;
+ /** Auth provider: "web" (password), "oauth2", "saml2", etc. */
+ authType?: string;
+ /** Raw stored authority (e.g. ROLE_USER, ROLE_WEB_ONLY_USER); preserved on team moves. */
+ authority?: string;
+}
+
+export interface Role {
+ id: RoleId;
+ label: string;
+ /** One-line summary of what the role can do. */
+ summary: string;
+ /** Concrete permission bullets shown in the reference grid. */
+ permissions: string[];
+ tone: "purple" | "blue" | "green" | "amber" | "neutral";
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Access controls (tier-scoped) */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/**
+ * Access posture for the org, shaped by tier. Free exposes only the seat limit
+ * and an upgrade nudge; pro adds session/MFA self-service; enterprise adds
+ * SSO/SAML, SCIM provisioning, enforced MFA and a session policy. Fields are
+ * optional so the panel renders whatever the tier returns.
+ */
+export interface AccessControls {
+ tier: Tier;
+ /** Seats consumed by active + invited members. */
+ seatsUsed: number;
+ /** Total seats on the plan; null = unlimited (enterprise). */
+ seatLimit: number | null;
+ /** Free only: copy for the upgrade nudge. */
+ upgradeHint?: string;
+ /** Pro+: end-user MFA available (self-service, not enforced). */
+ mfaAvailable?: boolean;
+ /** Enterprise: MFA enforced org-wide. */
+ mfaEnforced?: boolean;
+ /** Pro+: idle session timeout, e.g. "30 days" / "12 hours". */
+ sessionTimeout?: string;
+ /** Enterprise: SSO connection summary. */
+ sso?: {
+ provider: string;
+ status: "connected" | "not_configured";
+ /** Email domains that auto-route to SSO. */
+ domains: string[];
+ };
+ /** Enterprise: SCIM directory provisioning. */
+ scim?: {
+ enabled: boolean;
+ /** Where the directory syncs from, e.g. "Okta". */
+ directory: string;
+ lastSync: string;
+ };
+}
+
+export interface UsersSummary {
+ totalMembers: number;
+ pendingInvites: number;
+ seatsUsed: number;
+ /** null = unlimited. */
+ seatLimit: number | null;
+}
+
+export interface UsersResponse {
+ summary: UsersSummary;
+ members: Member[];
+ roles: Role[];
+ access: AccessControls;
+ /** Whether SMTP is configured (gates emailing passwords/invites). */
+ mailEnabled: boolean;
+ /** Whether email invites will work: SMTP on AND mail.enableInvites=true. Gates the
+ * "Invite by email" option on self-hosted. */
+ emailInvitesEnabled: boolean;
+}
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Presentation metadata — product copy, lives client-side */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+export const MEMBER_STATUS_TONE: Record<
+ MemberStatus,
+ "success" | "warning" | "danger" | "neutral" | "info"
+> = {
+ active: "success",
+ invited: "info",
+ suspended: "danger",
+};
+
+/* ──────────────────────────────────────────────────────────────────────── */
+/* Role catalogue */
+/* The same five roles exist on every tier — what varies is who can fill */
+/* them and how access is enforced, not the role definitions themselves. */
+/* ──────────────────────────────────────────────────────────────────────── */
+
+/** `label`/`summary`/`permissions` values are i18n keys — render with t(). */
+export const ROLES: Role[] = [
+ {
+ id: "admin",
+ label: "portal.users.roles.admin.label",
+ summary: "portal.users.roles.admin.summary",
+ permissions: [
+ "portal.users.roles.admin.permissions.0",
+ "portal.users.roles.admin.permissions.1",
+ "portal.users.roles.admin.permissions.2",
+ "portal.users.roles.admin.permissions.3",
+ ],
+ tone: "purple",
+ },
+ {
+ id: "team_owner",
+ label: "portal.users.roles.team_owner.label",
+ summary: "portal.users.roles.team_owner.summary",
+ permissions: [
+ "portal.users.roles.team_owner.permissions.0",
+ "portal.users.roles.team_owner.permissions.1",
+ "portal.users.roles.team_owner.permissions.2",
+ "portal.users.roles.team_owner.permissions.3",
+ ],
+ tone: "blue",
+ },
+ {
+ id: "member",
+ label: "portal.users.roles.member.label",
+ summary: "portal.users.roles.member.summary",
+ permissions: [
+ "portal.users.roles.member.permissions.0",
+ "portal.users.roles.member.permissions.1",
+ "portal.users.roles.member.permissions.2",
+ "portal.users.roles.member.permissions.3",
+ ],
+ tone: "green",
+ },
+ {
+ id: "guest",
+ label: "portal.users.roles.guest.label",
+ summary: "portal.users.roles.guest.summary",
+ permissions: [
+ "portal.users.roles.guest.permissions.0",
+ "portal.users.roles.guest.permissions.1",
+ "portal.users.roles.guest.permissions.2",
+ "portal.users.roles.guest.permissions.3",
+ ],
+ tone: "neutral",
+ },
+];
+
+export const ROLE_LABEL: Record = Object.fromEntries(
+ ROLES.map((r) => [r.id, r.label]),
+) as Record;
+
+export const ROLE_TONE: Record = Object.fromEntries(
+ ROLES.map((r) => [r.id, r.tone]),
+) as Record;
/** Roles an admin can assign from the portal; guest is derived, not assigned. */
export const ASSIGNABLE_ROLES: RoleId[] = ["admin", "team_owner", "member"];
diff --git a/frontend/editor/src/portal/components/Header.tsx b/frontend/editor/src/portal/components/Header.tsx
index 6ba062e577..76a3e5e45d 100644
--- a/frontend/editor/src/portal/components/Header.tsx
+++ b/frontend/editor/src/portal/components/Header.tsx
@@ -12,7 +12,6 @@ import {
ChevronDownIcon,
} from "@portal/components/icons";
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
-import { MocksToggle } from "@portal/components/MocksToggle";
import "@portal/components/Header.css";
function ThemeToggle() {
@@ -43,9 +42,9 @@ function ThemeToggle() {
function TierSwitcher() {
const { tier, setTier, isDerived } = useTier();
const info = TIER_INFO[tier];
- // When mocks are off, the tier is derived from the real link/wallet state —
- // pair the dropdown with the mocks toggle (hidden in prod) so testing real
- // billing flows can't be perturbed by accidentally flipping the mock tier.
+ // In the app the tier is derived from the real link/wallet state and can't
+ // be switched by hand; the dropdown only renders where the tier is pinned
+ // (Storybook / demo surfaces — see TierProvider's initialTier).
if (isDerived) return null;
return (
@@ -143,7 +142,6 @@ export function Header() {
-
diff --git a/frontend/editor/src/portal/components/MocksToggle.css b/frontend/editor/src/portal/components/MocksToggle.css
deleted file mode 100644
index 68e5262ede..0000000000
--- a/frontend/editor/src/portal/components/MocksToggle.css
+++ /dev/null
@@ -1,51 +0,0 @@
-.portal-mocks-toggle {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1_5);
- padding: var(--space-1) var(--space-2);
- font-family: var(--font-mono);
- font-size: 0.6875rem;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- border-radius: var(--radius-sm);
- border: 1px dashed transparent;
- transition:
- background var(--motion-fast),
- border-color var(--motion-fast),
- color var(--motion-fast);
-}
-
-.portal-mocks-toggle.is-on {
- color: var(--color-amber-dark);
- background: var(--color-amber-light);
- border-color: var(--color-amber-border);
-}
-
-.portal-mocks-toggle.is-off {
- color: var(--color-text-4);
- background: var(--color-bg-muted);
- border-color: var(--color-border);
-}
-
-.portal-mocks-toggle:hover {
- filter: brightness(1.04);
-}
-
-.portal-mocks-toggle.is-pending {
- opacity: 0.6;
- cursor: progress;
-}
-
-.portal-mocks-toggle__dot {
- width: 0.4375rem;
- height: 0.4375rem;
- border-radius: 50%;
- background: currentColor;
- box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
-}
-
-.portal-mocks-toggle.is-off .portal-mocks-toggle__dot {
- background: var(--color-text-5);
- box-shadow: none;
-}
diff --git a/frontend/editor/src/portal/components/MocksToggle.tsx b/frontend/editor/src/portal/components/MocksToggle.tsx
deleted file mode 100644
index a0a12d062e..0000000000
--- a/frontend/editor/src/portal/components/MocksToggle.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-///
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import {
- readMocksPreference,
- writeMocksPreference,
-} from "@portal/mocks/preference";
-import "@portal/components/MocksToggle.css";
-import { Button } from "@app/ui/Button";
-
-/**
- * Dev-only header chip that flips MSW interception on and off. Persists the
- * preference to localStorage so it survives reloads. Hidden entirely in
- * production builds — there's no MSW worker to toggle there.
- *
- * Toggling reloads the page. Without a reload, components that already
- * fetched data via useAsync keep showing the cached result, which makes the
- * toggle feel like it does nothing. A reload gives a clean view of what the
- * app looks like with/without mocks.
- */
-export function MocksToggle() {
- const { t } = useTranslation();
- const [enabled] = useState(() => readMocksPreference());
- const [pending, setPending] = useState(false);
-
- if (!import.meta.env.DEV) return null;
-
- function toggle() {
- if (pending) return;
- setPending(true);
- writeMocksPreference(!enabled);
- window.location.reload();
- }
-
- return (
-
- );
-}
diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx
index d10d08bf27..eb3ffdef13 100644
--- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx
+++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { listInstances, type LinkedInstanceRow } from "@portal/mocks/link";
+import type { LinkedInstanceRow } from "@portal/api/link";
+import { listInstances } from "@portal/mocks/link";
import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable";
import "@portal/views/AccountLink.css";
diff --git a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx
index 2e19223ce8..5357ffd52f 100644
--- a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx
+++ b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx
@@ -44,7 +44,7 @@ export function ComponentCard({
{component.name}
- {maturity.label}
+ {t(maturity.label)}
{!unlocked && (
- {formatPrice(component.pricing)}
+ {formatPrice(component.pricing, t)}
@stirling/{component.package}
diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx
index b2d8a3f173..337cfc6119 100644
--- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx
+++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx
@@ -75,7 +75,7 @@ export function ComponentDetailModal({
{component.name}
- {maturity.label}
+ {t(maturity.label)}
}
@@ -84,7 +84,7 @@ export function ComponentDetailModal({
unlocked ? (
- {formatPrice(component.pricing)}
+ {formatPrice(component.pricing, t)}
)}
diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css
index 6bad9365c5..75adbc6a85 100644
--- a/frontend/editor/src/portal/views/Policies.css
+++ b/frontend/editor/src/portal/views/Policies.css
@@ -225,13 +225,6 @@
font-weight: 600;
}
-.portal-policies__wizard-subheading {
- margin: 0.875rem 0 0;
- font-size: 0.75rem;
- font-weight: 600;
- color: var(--color-text-3);
-}
-
.portal-policies__fields {
display: flex;
flex-direction: column;
@@ -248,17 +241,28 @@
gap: 0.375rem;
}
-/* Workflow step — tool rows */
-.portal-policies__tool-head {
+/* Workflow step — policy capability settings list.
+ * Rows read as the policy's own settings (label + plain description + toggle),
+ * not a chain of distinct tools. Config reveals inline beneath an enabled row. */
+.portal-policies__capabilities {
display: flex;
- align-items: center;
- gap: 0.625rem;
+ flex-direction: column;
}
-.portal-policies__tool-name {
- font-size: 0.8125rem;
- font-weight: 600;
- color: var(--color-text-1);
+.portal-policies__capability {
+ padding: 0.75rem 0.875rem;
+}
+
+.portal-policies__capability + .portal-policies__capability {
+ border-top: 1px solid var(--color-border);
+}
+
+/* An enabled row with config gets a faint tint so the revealed settings read as
+ * belonging to it. */
+.portal-policies__capability[data-on] .portal-policies__capability-config {
+ border-top: 1px dashed var(--color-border);
+ padding-top: 0.75rem;
+ margin-top: 0.75rem;
}
/* Sources picker */
@@ -284,11 +288,32 @@
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
+ /* Shared Button pins a fixed control height and natural width; this tile is a
+ full-width, two-line card, so fill the grid cell and grow to fit content. */
+ width: 100%;
+ height: auto;
+ font-weight: inherit;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
+/* The shared Button wraps children in an inner/label node. Let the inner grow
+ (so the ::after check sits at the far right) and the label carry the
+ icon + two-line-text row. */
+.portal-policies__source .mantine-Button-inner {
+ flex: 1;
+ min-width: 0;
+}
+
+.portal-policies__source .mantine-Button-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ white-space: normal;
+}
+
.portal-policies__source::after {
content: "";
display: flex;
@@ -353,22 +378,6 @@
line-height: 1.4;
}
-/* Doc-type scope */
-.portal-policies__doctypes-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 0.8125rem;
- color: var(--color-text-2);
-}
-
-.portal-policies__doctypes {
- display: flex;
- flex-wrap: wrap;
- gap: 0.375rem;
- margin-top: 0.625rem;
-}
-
.portal-policies__link {
border: none;
background: none;
diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css
index 9c4683f0ac..feb698a644 100644
--- a/frontend/editor/src/proprietary/components/policies/Policies.css
+++ b/frontend/editor/src/proprietary/components/policies/Policies.css
@@ -90,24 +90,8 @@
outline: 2px solid var(--color-blue);
outline-offset: -2px;
}
-/* Policy icons are colourless at rest; hovering or focusing the row reveals the
- category colour (blue/purple/green/amber/red — see ROW_ACCENT). The accent
- class sets --ib-base; we neutralise --ib-accent here and restore it on hover. */
-.pol-row .sui-iconbadge {
- --ib-accent: var(--color-text-3);
- transition:
- color var(--motion-fast),
- background var(--motion-fast);
-}
-.pol-row:hover .sui-iconbadge,
-.pol-row:focus-visible .sui-iconbadge {
- --ib-accent: var(--ib-base);
-}
-
/* Processing indicator: a spinning ring around the category icon while the
- policy has runs in flight. The ring carries the category accent; the badge
- also shows its colour (not the neutral rest tint) so an active policy reads
- clearly even before hover. */
+ policy has runs in flight. */
.pol-row-icon {
position: relative;
display: inline-flex;
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx
index d9fef2361d..59fe76c021 100644
--- a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx
+++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx
@@ -1,4 +1,4 @@
-import { MultiSelect } from "@mantine/core";
+import { MultiSelect } from "@app/ui/MultiSelect";
import { useTranslation } from "react-i18next";
import { PII_PRESETS } from "@app/data/policyDefinitions";
@@ -49,8 +49,8 @@ export function PolicyPiiField({
return (
({
value: p.value,
@@ -62,7 +62,6 @@ export function PolicyPiiField({
onChange={handleChange}
disabled={disabled}
clearable
- checkIconPosition="right"
/>
);
}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx
index 692508a92e..8697c9d030 100644
--- a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx
+++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx
@@ -9,10 +9,10 @@ interface PolicyWatermarkConfigProps {
}
/**
- * Watermark configuration for a policy: the full watermark settings minus the
- * "Flatten PDF pages to images" toggle (hidden), with flatten forced on so the
- * watermark is baked into the page and can't be stripped out. Normalised once
- * on mount.
+ * Watermark configuration for a policy: text watermarks only (the type selector
+ * and image option are hidden), minus the "Flatten PDF pages to images" toggle
+ * (hidden), with flatten forced on so the watermark is baked into the page and
+ * can't be stripped out. Normalised once on mount.
*/
export function PolicyWatermarkConfig({
parameters,
@@ -20,9 +20,11 @@ export function PolicyWatermarkConfig({
disabled,
}: PolicyWatermarkConfigProps) {
useEffect(() => {
- if (parameters.convertPDFToImage !== true) {
- onChange({ ...parameters, convertPDFToImage: true });
- }
+ const patch: Record = {};
+ if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
+ // Policies only support text watermarks.
+ if (parameters.watermarkType !== "text") patch.watermarkType = "text";
+ if (Object.keys(patch).length > 0) onChange({ ...parameters, ...patch });
}, []);
return (
@@ -33,6 +35,7 @@ export function PolicyWatermarkConfig({
}
disabled={disabled}
showFlatten={false}
+ textOnly
/>
);
}
diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts
index 5fbd779725..ada31ad11b 100644
--- a/frontend/editor/src/proprietary/components/policies/policyStatus.ts
+++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts
@@ -17,10 +17,19 @@ export const STATUS_LABEL: Record = {
setup: "Set up",
};
-/**
- * Per-category accent colour
- */
+/** Per-category icon accent — neutral (no tint background) across all categories. */
export const ROW_ACCENT: Record = {
+ ingestion: "neutral",
+ security: "neutral",
+ compliance: "neutral",
+ routing: "neutral",
+ retention: "neutral",
+};
+
+/** Per-category colour for the file badges + enforcement overlay. Separate from
+ * ROW_ACCENT: the sidebar rows render neutral by design, but the badges keep
+ * their identity colours so files remain distinguishable at a glance. */
+const BADGE_ACCENT: Record = {
ingestion: "blue",
classification: "orange",
security: "purple",
@@ -30,15 +39,9 @@ export const ROW_ACCENT: Record = {
};
/**
- * CSS colour var for a policy category's accent (blue for unknown categories) —
- * the tint used by the file badges and the enforcement overlay.
- *
- * Derived straight from the accent name (`--color-`), which is exactly
- * the token {@link IconBadge} uses for the same accent. Deriving it (rather than
- * keeping a second name→var map) means the badge tint can never drift from the
- * sidebar's colour — previously `orange` was missing from that map, so the
- * Classification badge/overlay rendered untinted while its sidebar row was orange.
+ * CSS colour var for a policy category's badge accent (blue for unknown
+ * categories) — the tint used by the file badges and the enforcement overlay.
*/
export function policyAccentVar(categoryId: string): string {
- return `var(--color-${ROW_ACCENT[categoryId] ?? "blue"})`;
+ return `var(--color-${BADGE_ACCENT[categoryId] ?? "blue"})`;
}