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] 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)}
@@ -90,7 +90,7 @@ export function DealJourney({ loading={advancing} onClick={() => onAdvance(currentStage)} > - {currentStep.gatingAction} + {t(currentStep.gatingAction)} )}
diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx index 8189d006f8..72ab22a341 100644 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx +++ b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx @@ -78,7 +78,9 @@ export function DocumentLedger({ {group.label} {blurb && ( - · {blurb} + + · {t(blurb)} + )} {cur && ( diff --git a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx index e88c810801..eae9db4d99 100644 --- a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { LockedState } from "@portal/components/procurement/LockedState"; -import { JOURNEY } from "@portal/mocks/procurement"; +import { JOURNEY } from "@portal/api/procurement"; import "@portal/views/Procurement.css"; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx b/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx index a27b0b90aa..7adb6951fb 100644 --- a/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { StageStepper } from "@portal/components/procurement/StageStepper"; -import { JOURNEY } from "@portal/mocks/procurement"; +import { JOURNEY } from "@portal/api/procurement"; import "@portal/views/Procurement.css"; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/procurement/StageStepper.tsx b/frontend/editor/src/portal/components/procurement/StageStepper.tsx index 1981078459..3b17f9a0d9 100644 --- a/frontend/editor/src/portal/components/procurement/StageStepper.tsx +++ b/frontend/editor/src/portal/components/procurement/StageStepper.tsx @@ -1,4 +1,5 @@ import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; import type { DealStage, JourneyStep } from "@portal/api/procurement"; /** Status of a step relative to the deal's current stage. */ @@ -19,6 +20,7 @@ export function StageStepper({ currentStage: DealStage; locked?: boolean; }) { + const { t } = useTranslation(); const order = journey.map((s) => s.stage); const curIdx = locked ? -1 : order.indexOf(currentStage); @@ -40,7 +42,7 @@ export function StageStepper({ )}
- {step.label} + {t(step.label)}
); diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx index 305bcc622d..515c95923e 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx @@ -41,9 +41,10 @@ interface InviteMemberModalProps { type InviteRole = "member" | "admin"; type Mode = "email" | "direct"; -const ROLE_SELECT_OPTIONS: { value: InviteRole; label: string }[] = [ - { value: "member", label: ROLE_LABEL.member }, - { value: "admin", label: ROLE_LABEL.admin }, +// Values hold i18n keys; resolved with t() where the select renders. +const ROLE_SELECT_OPTIONS: { value: InviteRole; labelKey: string }[] = [ + { value: "member", labelKey: ROLE_LABEL.member }, + { value: "admin", labelKey: ROLE_LABEL.admin }, ]; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -117,9 +118,11 @@ export function InviteMemberModal({ }, [email, username, password]); // Drop the "admin" (Org Owner) option where it can't be assigned (SaaS). - const roleOptions = adminRole - ? ROLE_SELECT_OPTIONS - : ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin"); + const roleOptions = ( + adminRole + ? ROLE_SELECT_OPTIONS + : ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin") + ).map((o) => ({ value: o.value, label: t(o.labelKey) })); const authTypeOptions: { value: AuthType; label: string }[] = [ { value: "WEB", label: t("users.invite.authWeb", "Password") }, diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index 1b4742447b..cb8a730640 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -1,12 +1,10 @@ import { createContext, useContext, - useEffect, useMemo, useState, type ReactNode, } from "react"; -import { readMocksPreference } from "@portal/mocks/preference"; import { usePlanTier } from "@portal/contexts/usePlanTier"; export type Tier = "free" | "pro" | "enterprise"; @@ -26,9 +24,9 @@ export const TIER_INFO: Record = { interface TierContextValue { tier: Tier; - /** No-op when MSW mocks are off (tier is derived from real link state). */ + /** No-op when the tier is derived from the real plan (i.e. in the app). */ setTier: (tier: Tier) => void; - /** True when the tier value is derived from the real wallet/link, not the dropdown. */ + /** True when the tier value is derived from the real plan, not pinned. */ isDerived: boolean; } @@ -36,38 +34,33 @@ const TierContext = createContext(null); export function TierProvider({ children, - initialTier = "pro", + initialTier, }: { children: ReactNode; + /** + * Pins the tier to a fixed, locally settable value. Storybook and demo + * surfaces pass this to stage a specific tier; the app omits it, so the + * tier is always derived from the real plan (see usePlanTier — link state + * self-hosted, wallet on SaaS). + */ initialTier?: Tier; }) { - // Mocks toggling reloads the page (see MocksToggle), so a single read at mount - // is correct — the preference can't change without us remounting. - const mocksOn = useMemo(() => readMocksPreference(), []); - // Real derived tier. Its source is a per-flavor seam: self-hosted derives it - // from the link/subscription state, SaaS from the wallet (see usePlanTier). + const pinned = initialTier !== undefined; + const [pinnedTier, setPinnedTier] = useState(initialTier ?? "free"); const derivedTier = usePlanTier(); - const [mockTier, setMockTier] = useState(initialTier); - - // When mocks are off, mirror the derived tier so any component keyed on `tier` - // (sidebar plan badge, gated panels) stays consistent. When mocks are on, the - // dropdown wins. - useEffect(() => { - if (!mocksOn) { - setMockTier(derivedTier); - } - }, [mocksOn, derivedTier]); - + // Memo on the resolved tier (not its inputs) so plan transitions that map to + // the same tier don't re-render every consumer. + const tier = pinned ? pinnedTier : derivedTier; const value = useMemo( () => ({ - tier: mocksOn ? mockTier : derivedTier, - // Setter is a no-op when mocks are off — UI controls can disable themselves + tier, + // Setter is a no-op when derived — UI controls can disable themselves // via `isDerived`, but even if one slips through, it has no effect. - setTier: mocksOn ? setMockTier : () => {}, - isDerived: !mocksOn, + setTier: pinned ? setPinnedTier : () => {}, + isDerived: !pinned, }), - [mocksOn, mockTier, derivedTier], + [pinned, tier], ); return {children}; diff --git a/frontend/editor/src/portal/mocks/agents.ts b/frontend/editor/src/portal/mocks/agents.ts index e8523d11c7..6ff7b14910 100644 --- a/frontend/editor/src/portal/mocks/agents.ts +++ b/frontend/editor/src/portal/mocks/agents.ts @@ -1,125 +1,14 @@ /** - * Agent Builder fixtures and the types api/agents.ts shares with them. + * Agent Builder fixtures. Types live in api/agents.ts (the backend contract); + * this module only builds fake data for Storybook and tests. * * 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. - * - * api/agents.ts imports the types; the MSW handlers serve this fixture data - * over the intercepted apiClient.local.json() calls. Components never reach into this - * module directly. Once a real backend exists the handlers stop being - * registered and these fixtures can be deleted (or kept as test seeds). */ import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* 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; +import type { Agent, AgentsResponse, AgentsSummary } from "@portal/api/agents"; /* ──────────────────────────────────────────────────────────────────────── */ /* Fixture builders */ diff --git a/frontend/editor/src/portal/mocks/browser.ts b/frontend/editor/src/portal/mocks/browser.ts deleted file mode 100644 index 077b41b456..0000000000 --- a/frontend/editor/src/portal/mocks/browser.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { setupWorker } from "msw/browser"; -import { embeddedDataHandlers } from "@portal/mocks/handlers"; - -// Data handlers only (see embeddedDataHandlers) and no seeded auth token: the -// portal shares an origin and auth session with the host editor, so mocking auth -// or seeding a token would log the editor out. -export const worker = setupWorker(...embeddedDataHandlers); - -let workerStarted = false; - -/** - * Start the MSW worker. Idempotent — calling repeatedly is safe. - * - * The toggle flips MSW by writing the preference to localStorage and - * reloading the page, so there's no need for a `stopMockWorker` counterpart: - * the next boot just decides whether to call this or not. - */ -export async function startMockWorker(): Promise { - if (workerStarted) return; - await worker.start({ - onUnhandledRequest: "bypass", - serviceWorker: { url: "/mockServiceWorker.js" }, - quiet: true, - }); - workerStarted = true; -} diff --git a/frontend/editor/src/portal/mocks/docs.ts b/frontend/editor/src/portal/mocks/docs.ts index 2b54d0c17c..9c3c8c3473 100644 --- a/frontend/editor/src/portal/mocks/docs.ts +++ b/frontend/editor/src/portal/mocks/docs.ts @@ -1,8 +1,6 @@ /** - * Developer Docs fixtures and the types api/docs.ts shares with them. - * api/docs.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted apiClient.local.json() calls. Components never reach - * into this module directly. + * Developer Docs fixtures. Types live in api/docs.ts (the backend contract); + * this module only builds fake data for Storybook and tests. * * Two payloads back the surface: * - the left-hand nav tree (`buildDocsNav`), and @@ -12,37 +10,25 @@ * * Rate limits scale with plan: free is throttled hard, pro lifts the ceiling, * enterprise is negotiated ("Custom"). The rest of the content is tier-neutral. - * - * Once a real backend exists the MSW handlers stop being registered and these - * fixtures can be deleted (or kept as test seeds). */ -import type { CardAccent } from "@app/ui"; import type { Tier } from "@portal/contexts/TierContext"; -import type { CodeLang } from "@app/ui"; +import type { + AgentSkill, + ApiErrorRow, + CodeSample, + DocsContent, + DocsNavSection, + EmbedComponent, + Playbook, + RateLimit, + Sdk, +} from "@portal/api/docs"; /* ──────────────────────────────────────────────────────────────────────── */ /* 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[]; -} - export function buildDocsNav(): DocsNavSection[] { return [ { @@ -96,79 +82,6 @@ export function buildDocsNav(): DocsNavSection[] { /* 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[]; -} - const QUICKSTART_SAMPLES: CodeSample[] = [ { key: "curl", diff --git a/frontend/editor/src/portal/mocks/documents.ts b/frontend/editor/src/portal/mocks/documents.ts index b4b7fe2325..02615280bf 100644 --- a/frontend/editor/src/portal/mocks/documents.ts +++ b/frontend/editor/src/portal/mocks/documents.ts @@ -11,136 +11,20 @@ */ import type { Tier } from "@portal/contexts/TierContext"; -import type { ChipAccent, StatusTone } from "@app/ui"; +import type { + DocAuditEvent, + DocAuditKind, + DocumentStatus, + DocumentsResponse, + DocumentsSummary, + ProductType, + ReviewDocument, +} from "@portal/api/documents"; /* ──────────────────────────────────────────────────────────────────────── */ /* Domain types */ /* ──────────────────────────────────────────────────────────────────────── */ -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) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const DOCUMENT_STATUS_LABEL: Record = { - processed: "Processed", - flagged: "Needs Review", - "in-review": "In Review", - error: "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"; -} - -export const DOC_AUDIT_LABEL: Record = { - ingested: "Ingested", - extracted: "Processed", - flagged: "Needs Review", - reviewed: "In Review", - approved: "Approved", - archived: "Archived", - elevation: "Elevation", -}; - -export const DOC_AUDIT_TONE: Record = { - ingested: "info", - extracted: "success", - flagged: "warning", - reviewed: "purple", - approved: "success", - archived: "neutral", - elevation: "purple", -}; - /* ──────────────────────────────────────────────────────────────────────── */ /* Fixture builder */ /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/frontend/editor/src/portal/mocks/editorDeploy.ts b/frontend/editor/src/portal/mocks/editorDeploy.ts index 32d98432f2..04ba214f8e 100644 --- a/frontend/editor/src/portal/mocks/editorDeploy.ts +++ b/frontend/editor/src/portal/mocks/editorDeploy.ts @@ -13,143 +13,13 @@ */ import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* 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, - "success" | "warning" | "danger" | "info" | "neutral" -> = { - healthy: "success", - degraded: "warning", - offline: "danger", - pairing: "info", -}; - -export const INSTANCE_STATUS_LABEL: Record = { - healthy: "Healthy", - degraded: "Degraded", - offline: "Offline", - pairing: "Pairing", -}; +import type { + DeploymentSummary, + DeploymentTarget, + EditorDeploymentResponse, + EditorInstance, + PairingOption, +} from "@portal/api/editorDeploy"; /* ──────────────────────────────────────────────────────────────────────── */ /* Snippet builders */ diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 0ce3059ff6..8d2139a0b8 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -38,32 +38,5 @@ export const handlers = [ ...linkHandlers, ]; -/** - * The handlers safe to run when the portal shares an origin with the editor. - * Three groups are excluded because their routes overlap endpoints the editor - * itself calls, so mocking them breaks the host app: - * - authHandlers: /api/v1/auth/*, /api/v1/proprietary/ui-data/login (logs the - * editor out; the portal uses the editor's real session instead) - * - policiesHandlers + pipelinesHandlers: both /api/v1/policies* (the editor's - * own policies feature) - * Everything kept is portal-only. `handlers` above is still the full set. - */ -export const embeddedDataHandlers = [ - ...notificationsHandlers, - ...assistantHandlers, - ...searchHandlers, - ...sourcesHandlers, - ...infrastructureHandlers, - ...docsHandlers, - ...procurementHandlers, - ...settingsHandlers, - ...usersHandlers, - ...agentsHandlers, - ...documentsHandlers, - ...sdkComponentsHandlers, - ...editorDeployHandlers, - ...linkHandlers, -]; - export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; export { resetProcurementStore } from "@portal/mocks/handlers/procurement"; diff --git a/frontend/editor/src/portal/mocks/handlers/link.ts b/frontend/editor/src/portal/mocks/handlers/link.ts index 6ac6781f71..1724ce375f 100644 --- a/frontend/editor/src/portal/mocks/handlers/link.ts +++ b/frontend/editor/src/portal/mocks/handlers/link.ts @@ -1,4 +1,5 @@ import { http, HttpResponse, delay } from "msw"; +import type { LinkInstanceRequest } from "@portal/api/link"; import { getLocalStatus, getLocalUsage, @@ -6,7 +7,6 @@ import { listInstances, revokeInstance, unlinkLocal, - type LinkInstanceRequest, } from "@portal/mocks/link"; /** diff --git a/frontend/editor/src/portal/mocks/handlers/notifications.ts b/frontend/editor/src/portal/mocks/handlers/notifications.ts index e9c809ac52..e3977f543a 100644 --- a/frontend/editor/src/portal/mocks/handlers/notifications.ts +++ b/frontend/editor/src/portal/mocks/handlers/notifications.ts @@ -1,5 +1,6 @@ import { http, HttpResponse, delay } from "msw"; -import { NOTIFICATIONS, type Notification } from "@portal/mocks/notifications"; +import type { Notification } from "@portal/api/notifications"; +import { NOTIFICATIONS } from "@portal/mocks/notifications"; let store: Notification[] = [...NOTIFICATIONS]; diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts index 8d35ae4535..16bc0933e6 100644 --- a/frontend/editor/src/portal/mocks/handlers/policies.ts +++ b/frontend/editor/src/portal/mocks/handlers/policies.ts @@ -1,10 +1,6 @@ import { http, HttpResponse, delay } from "msw"; -import { - seedPolicies, - seedPolicyRuns, - type WirePolicy, -} from "@portal/mocks/policies"; -import type { PolicyRunView } from "@app/policies/types"; +import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies"; +import type { PolicyRunView, WirePolicy } from "@app/policies/types"; /** * The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the diff --git a/frontend/editor/src/portal/mocks/handlers/procurement.ts b/frontend/editor/src/portal/mocks/handlers/procurement.ts index e0e6f497c2..37dbc2f75a 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurement.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurement.ts @@ -2,10 +2,12 @@ import { http, HttpResponse, delay } from "msw"; import type { Tier } from "@portal/contexts/TierContext"; import { JOURNEY, - buildProcurement, - seedEnterpriseDeal, type DealStage, type ProcurementResponse, +} from "@portal/api/procurement"; +import { + buildProcurement, + seedEnterpriseDeal, } from "@portal/mocks/procurement"; import { advanceDeal, diff --git a/frontend/editor/src/portal/mocks/infrastructure.ts b/frontend/editor/src/portal/mocks/infrastructure.ts index 5b33e324fb..85aac3eeea 100644 --- a/frontend/editor/src/portal/mocks/infrastructure.ts +++ b/frontend/editor/src/portal/mocks/infrastructure.ts @@ -13,45 +13,29 @@ */ import type { Tier } from "@portal/contexts/TierContext"; +import type { + ApiKey, + AuditEvent, + AuditLogResponse, + AuditSummary, + ComplianceAttestation, + ComplianceCert, + DeploymentRegion, + IpAllowEntry, + KeyManagement, + ModelEntry, + ModelsResponse, + RecentDeployment, + RoutingRule, + SecurityConfig, + StorageConfig, + StorageProvider, +} from "@portal/api/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; -} - const REGION_US_EAST: DeploymentRegion = { name: "US East (N. Virginia)", code: "us-east-1", @@ -178,25 +162,6 @@ export function recentDeploymentsFor(tier: Tier): RecentDeployment[] { /* 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; -} - const API_KEYS_ALL: ApiKey[] = [ { id: "key-1", @@ -262,77 +227,6 @@ export function apiKeysFor(tier: Tier): ApiKey[] { /* 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[]; -} - const CERTS_FULL: ComplianceCert[] = [ { id: "soc2", @@ -559,26 +453,6 @@ export function securityFor(tier: Tier): SecurityConfig { /* 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[]; -} - const PROVIDERS_FULL: StorageProvider[] = [ { id: "stirling", @@ -635,26 +509,6 @@ export function storageFor(tier: Tier): StorageConfig { /* 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; -} - // Mirrors what the real backend (PortalInfraAuditService) returns: audit_events // mapped from real AuditEventType values to the tab's categories. Only real // types appear - there is no audited "elevation" event, so that category is @@ -842,77 +696,10 @@ const AUDIT_EVENTS_ALL: AuditEvent[] = [ }, ]; -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[]; -} - const MODELS_ALL: ModelEntry[] = [ { id: "m-extract-v3", diff --git a/frontend/editor/src/portal/mocks/link.ts b/frontend/editor/src/portal/mocks/link.ts index 0dd11e104c..92b4b90f2c 100644 --- a/frontend/editor/src/portal/mocks/link.ts +++ b/frontend/editor/src/portal/mocks/link.ts @@ -1,5 +1,6 @@ /** - * Account-link fixtures and the types api/link.ts shares with them. + * Account-link fixtures. Types live in api/link.ts (the backend contract); + * this module only builds fake data for Storybook and tests. * * "Mode A" combined billing: a self-hosted instance links the org's SaaS account * so its unattended calls bill against the org wallet. Two surfaces: @@ -11,61 +12,17 @@ * - TEAM-WIDE management: the SaaS backend (`GET /instances`, * `POST /instances/{id}/revoke`), called with the admin's JWT. * - * api/link.ts imports the types; the MSW handlers in mocks/handlers/link.ts serve - * this fixture data over the intercepted apiClient.local.json() calls. Components never reach - * into this module directly. Once the real backend is wired the handlers stop - * being registered and these fixtures can be deleted (or kept as test seeds). + * The MSW handlers in mocks/handlers/link.ts serve this fixture data over the + * intercepted apiClient.local.json() calls. Components never reach into this + * module directly. Once the real backend is wired the handlers stop being + * registered and these fixtures can be deleted (or kept as test seeds). */ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Local backend — link / status / unlink (this instance) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** 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; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* SaaS backend — team-wide instance management */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** 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; -} +import type { + LinkStatus, + LinkedInstanceRow, + LocalUsage, +} from "@portal/api/link"; /* ──────────────────────────────────────────────────────────────────────── */ /* Mock store — link/unlink/revoke mutate this so the surface feels live */ diff --git a/frontend/editor/src/portal/mocks/notifications.ts b/frontend/editor/src/portal/mocks/notifications.ts index b267bbe34c..12f32470f9 100644 --- a/frontend/editor/src/portal/mocks/notifications.ts +++ b/frontend/editor/src/portal/mocks/notifications.ts @@ -1,21 +1,10 @@ -/** Mock notifications for the header dropdown. */ +/** + * Notification fixtures for the header dropdown. Types live in + * api/notifications.ts (the backend contract); this module only builds fake + * data for Storybook and tests. + */ -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; -} +import type { Notification } from "@portal/api/notifications"; export const NOTIFICATIONS: Notification[] = [ { diff --git a/frontend/editor/src/portal/mocks/policies.ts b/frontend/editor/src/portal/mocks/policies.ts index b06effe1e1..27d5dfeadb 100644 --- a/frontend/editor/src/portal/mocks/policies.ts +++ b/frontend/editor/src/portal/mocks/policies.ts @@ -1,362 +1,11 @@ /** - * Policies fixtures and the canonical TS model the portal shares with them. - * - * Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec - * layer and match the backend record exactly. Catalogue and UI types - * (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only: - * the backend has no "category" concept — `categoryId` rides in - * `output.options`. The catalogue assembles client-side in `api/policies.ts` - * from the decoded wire records + these static definitions. - * - * api/policies.ts re-exports everything; components never reach in here. + * Policies fixtures. The canonical TS model and the static catalogue + * definitions live in api/policies.ts (the backend contract); this module + * only builds seed data for the MSW handlers and tests. */ -import type { WirePipelineStep, WirePolicy } from "@app/policies/types"; -import type { PolicyRunView } from "@app/policies/types"; - -export type { - PolicyActivityItem, - PolicyDecodedState, - PolicyRunStatus, - PolicyRunView, - PolicyStats, - WireOutputOptions, - WireOutputSpec, - WirePipelineStep, - WirePolicy, -} from "@app/policies/types"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Catalogue model — portal-specific (lifted from editor types/policies.ts) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -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", -}; - -export const ENDPOINT_LABELS: Record = { - "/api/v1/security/auto-redact": "Redact PII", - "/api/v1/security/sanitize-pdf": "Remove JavaScript", - "/api/v1/security/add-watermark": "Watermark", - "/api/v1/misc/ocr-pdf": "OCR", - "/api/v1/misc/flatten": "Flatten", - "/api/v1/misc/compress-pdf": "Compress", -}; - -export function humanizeEndpoint(path: string): string { - if (ENDPOINT_LABELS[path]) return 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", -]; - -export const POLICY_CATEGORIES: PolicyCategory[] = [ - { - id: "ingestion", - label: "Ingestion", - icon: "layers", - tone: "blue", - desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.", - providesClassification: true, - comingSoon: true, - }, - { - id: "security", - label: "Security", - icon: "shield", - tone: "purple", - desc: "Detect PII, redact, strip active content, and watermark documents.", - }, - { - id: "compliance", - label: "Compliance", - icon: "check", - tone: "amber", - desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.", - comingSoon: true, - }, - { - id: "routing", - label: "Routing", - icon: "route", - tone: "green", - desc: "Auto-route documents to the right team, folder, or system.", - comingSoon: true, - }, - { - id: "retention", - label: "Retention", - icon: "clock", - tone: "neutral", - desc: "Set how long documents are kept, when to archive, and when to delete.", - comingSoon: true, - }, -]; - -export const POLICY_CONFIG: Record = { - ingestion: { - summary: - "Classifies documents, extracts structured data, enforces naming, and normalizes pages.", - rules: ["Classify", "Extract", "Name", "Normalize"], - scopeLabel: "All documents", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.ocr, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], - fields: [ - { - label: "Min confidence", - key: "minConfidence", - type: "select", - value: "80%", - options: ["60%", "70%", "80%", "90%", "95%"], - }, - { - label: "Below threshold", - key: "belowThreshold", - type: "select", - value: "Flag for review", - options: ["Flag for review", "Route to bucket", "Hold"], - }, - ], - }, - security: { - summary: - "Detects and redacts PII, strips active content (JavaScript), and watermarks documents.", - rules: ["Redact PII", "Remove JavaScript", "Watermark"], - scopeLabel: "All documents", - 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: - "Validates documents against regulatory frameworks before they leave the system.", - rules: ["Framework scan", "Enforce action", "Audit trail"], - scopeLabel: "All documents", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.sanitize, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], - fields: [ - { - label: "Frameworks", - key: "frameworks", - type: "chips", - value: ["HIPAA"], - options: ["HIPAA", "GDPR", "SOC 2", "FedRAMP", "PCI DSS", "ISO 27001"], - }, - { - label: "When non-compliant", - key: "onViolation", - type: "select", - value: "Flag for review", - options: [ - "Flag for review", - "Block export", - "Auto-redact PHI", - "Quarantine document", - ], - }, - { label: "Audit trail", key: "auditTrail", type: "toggle", value: true }, - { label: "Access log", key: "accessLog", type: "toggle", value: true }, - ], - }, - routing: { - summary: - "Routes documents to the right destination based on type and classification.", - rules: ["Auto-classify", "Route to folder", "Webhook notify"], - scopeLabel: "All documents", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], - fields: [ - { - label: "Destination", - key: "destination", - type: "select", - value: "Documents", - options: ["Documents", "S3 bucket", "SharePoint", "Webhook"], - }, - { label: "Webhook URL", key: "webhookUrl", type: "text", value: "" }, - { label: "Notify on route", key: "notify", type: "toggle", value: false }, - ], - }, - retention: { - summary: - "Enforces how long documents are kept, when to archive, and when to delete.", - rules: ["Retention hold", "Auto-archive", "Deletion block"], - scopeLabel: "All documents", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], - fields: [ - { - label: "Keep for", - key: "keepFor", - type: "select", - value: "7 years", - options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"], - }, - { - label: "Archive after", - key: "archiveAfter", - type: "select", - value: "Never", - options: ["30 days", "90 days", "1 year", "Never"], - }, - { - label: "Immutable hold", - 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", -]; +import type { PolicyRunView, WirePolicy } from "@app/policies/types"; +import { POLICY_CONFIG } from "@portal/api/policies"; /* ──────────────────────────────────────────────────────────────────────── */ /* Seed data — real backend wire format */ diff --git a/frontend/editor/src/portal/mocks/preference.ts b/frontend/editor/src/portal/mocks/preference.ts deleted file mode 100644 index ebd134c7e9..0000000000 --- a/frontend/editor/src/portal/mocks/preference.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// - -/** - * Lightweight preference helpers — pulled out of mocks/browser.ts so they - * don't drag MSW + every handler + every fixture into any chunk that just - * needs to *read* the user's choice. Loading the actual worker stays a - * dynamic import. - */ - -const STORAGE_KEY = "stirling.portal.mocks-enabled"; - -export function readMocksPreference(): boolean { - if (typeof window === "undefined") return false; - // An explicit user toggle (persisted) always wins. - const stored = window.localStorage.getItem(STORAGE_KEY); - if (stored === "true") return true; - if (stored === "false") return false; - // Build-time default: VITE_PORTAL_MOCKS forces mocks on/off. The single-origin - // proxy sets it false so the portal hits the real backend (otherwise the dev - // mock worker would seed a fake token over the shared real one). Falls back to - // on-in-dev, off-in-production. - const envDefault = import.meta.env.VITE_PORTAL_MOCKS; - if (envDefault === "true") return true; - if (envDefault === "false") return false; - return import.meta.env.DEV; -} - -export function writeMocksPreference(enabled: boolean): void { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, String(enabled)); -} diff --git a/frontend/editor/src/portal/mocks/procurement.ts b/frontend/editor/src/portal/mocks/procurement.ts index 4faeefa491..1f3f5a1934 100644 --- a/frontend/editor/src/portal/mocks/procurement.ts +++ b/frontend/editor/src/portal/mocks/procurement.ts @@ -1,194 +1,21 @@ /** - * Procurement fixtures and the types api/procurement.ts shares with them. - * api/procurement.ts imports the types; the MSW handlers in - * mocks/handlers/procurement.ts serve this fixture data over the intercepted - * httpJson() calls. Components never reach into this module directly. + * Procurement fixtures. Types and the journey definition live in + * api/procurement.ts (the backend contract); this module only builds the fake + * deal data the MSW handlers in mocks/handlers/procurement.ts serve over the + * intercepted httpJson() calls, for Storybook and tests. * - * 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. - * - * Once a real commercial backend exists the MSW handlers stop being registered - * and these fixtures can be deleted (or kept as test seeds). + * The handlers serve Storybook and tests, so these fixtures stay in sync with + * the api contract for as long as those need them. */ import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* 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. */ -export const JOURNEY: JourneyStep[] = [ - { - stage: "trial", - label: "Trial", - blurb: "Evaluate Stirling against your documents and workflows.", - gatingAction: "Build your quote", - }, - { - stage: "quote", - label: "Quote", - blurb: "Review committed-volume pricing and contract term.", - gatingAction: "Accept your quote", - }, - { - stage: "security", - label: "Agreement", - blurb: "One signature covers MSA, order form, EULA and DPA.", - gatingAction: "Review and sign your agreement", - }, - { - stage: "procurement", - label: "Payment", - blurb: "Pay by card, bank transfer, or against a purchase order.", - gatingAction: "Confirm payment", - }, - { - stage: "active", - label: "Implementation", - blurb: "Provision your workspace and run the go-live playbook.", - gatingAction: "Provisioning your workspace", - }, -]; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* 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[]; -} +import type { + Deal, + LedgerGroup, + ProcurementResponse, + SupportingGroup, +} from "@portal/api/procurement"; +import { JOURNEY } from "@portal/api/procurement"; /* ──────────────────────────────────────────────────────────────────────── */ /* Fixtures */ diff --git a/frontend/editor/src/portal/mocks/procurementMachine.ts b/frontend/editor/src/portal/mocks/procurementMachine.ts index 275b511b51..d889691e2b 100644 --- a/frontend/editor/src/portal/mocks/procurementMachine.ts +++ b/frontend/editor/src/portal/mocks/procurementMachine.ts @@ -17,7 +17,7 @@ import { type LedgerDoc, type LedgerGroup, type SupportingGroup, -} from "@portal/mocks/procurement"; +} from "@portal/api/procurement"; export interface ProcurementStore { deal: Deal; diff --git a/frontend/editor/src/portal/mocks/sdkComponents.ts b/frontend/editor/src/portal/mocks/sdkComponents.ts index aed16d2028..f86797637c 100644 --- a/frontend/editor/src/portal/mocks/sdkComponents.ts +++ b/frontend/editor/src/portal/mocks/sdkComponents.ts @@ -1,126 +1,24 @@ /** - * Components surface fixtures and the types api/sdkComponents.ts shares with them. + * Components surface fixtures. Types and presentation metadata live in + * api/sdkComponents.ts (the backend contract); this module only builds fake + * data for Storybook and tests. * * "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. - * - * api/sdkComponents.ts imports the types; the MSW handlers serve the fixture - * data over the intercepted apiClient.local.json() calls. Components never reach into this - * module directly. Once a real backend exists the handlers stop being - * registered and these fixtures can be deleted (or kept as test seeds). */ +import { isUnlocked } from "@portal/api/sdkComponents"; +import type { + ComponentPricing, + ComponentsResponse, + ComponentsSummary, + SdkComponent, +} from "@portal/api/sdkComponents"; import type { Tier } from "@portal/contexts/TierContext"; -/* ──────────────────────────────────────────────────────────────────────── */ -/* 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"; -} - -export const MATURITY_META: Record = { - ga: { label: "GA", tone: "success" }, - beta: { label: "Beta", tone: "info" }, -}; - -/** Human label for a billing unit, e.g. "render" → "/render". */ -export const BILLING_UNIT_LABEL: Record = { - render: "render", - review: "review", - approval: "approval", - signature: "signature", - check: "check", - event: "event", - session: "session", -}; - -/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */ -export function formatPrice(pricing: ComponentPricing): string { - return `$${pricing.pricePerAction.toFixed(2)} / ${BILLING_UNIT_LABEL[pricing.unit]}`; -} - /* ──────────────────────────────────────────────────────────────────────── */ /* Fixtures */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -463,8 +361,6 @@ export function MergeButton({ files }: { files: File[] }) { /* Tier shaping */ /* ──────────────────────────────────────────────────────────────────────── */ -const TIER_RANK: Record = { free: 0, pro: 1, enterprise: 2 }; - /** * Enterprise negotiates volume pricing — renders and reviews come in cheaper. * Applied as a flat per-tier multiplier so the catalogue stays single-sourced. @@ -486,11 +382,6 @@ export function componentsFor(tier: Tier): SdkComponent[] { return CATALOGUE.map((c) => ({ ...c, pricing: priceFor(c, tier) })); } -/** 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]; -} - export function summaryFor(tier: Tier): ComponentsSummary { const components = componentsFor(tier); const unlocked = components.filter((c) => isUnlocked(c, tier)); diff --git a/frontend/editor/src/portal/mocks/search.ts b/frontend/editor/src/portal/mocks/search.ts index f8c5990c7f..86d332de70 100644 --- a/frontend/editor/src/portal/mocks/search.ts +++ b/frontend/editor/src/portal/mocks/search.ts @@ -1,11 +1,10 @@ -/** Mock quick-action catalogue for the ⌘K search palette. */ +/** + * Mock quick-action catalogue for the ⌘K search palette. The QuickAction type + * lives in api/search.ts (the backend contract); this module only builds fake + * data for Storybook and tests. + */ -export interface QuickAction { - group: "Jump to" | "Create" | "Theme"; - label: string; - /** Keyboard hint shown to the right. */ - hint: string; -} +import type { QuickAction } from "@portal/api/search"; export const QUICK_ACTIONS: QuickAction[] = [ { group: "Jump to", label: "Home", hint: "G H" }, diff --git a/frontend/editor/src/portal/mocks/settings.ts b/frontend/editor/src/portal/mocks/settings.ts index 99bcf313cb..59cceb17d8 100644 --- a/frontend/editor/src/portal/mocks/settings.ts +++ b/frontend/editor/src/portal/mocks/settings.ts @@ -1,88 +1,22 @@ /** - * Account-settings fixtures and the types api/settings.ts shares with them. - * api/settings.ts imports the types; the MSW handlers in mocks/handlers/ serve - * the fixture data over the intercepted apiClient.local.json() call. Components never reach - * into this module directly. + * Account-settings fixtures. Types live in api/settings.ts (the backend + * contract); this module only builds fake data for Storybook and tests. * * 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. */ +import type { + ActiveSession, + BetaFeature, + NotificationDefault, + RegionOption, + SecuritySettings, + SettingsSnapshot, +} from "@portal/api/settings"; import type { Tier } from "@portal/contexts/TierContext"; -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[]; -} - const REGIONS: RegionOption[] = [ { value: "us-east-1", label: "US East (N. Virginia)" }, { value: "us-west-2", label: "US West (Oregon)" }, diff --git a/frontend/editor/src/portal/mocks/startIfEnabled.ts b/frontend/editor/src/portal/mocks/startIfEnabled.ts deleted file mode 100644 index 74ffe5b386..0000000000 --- a/frontend/editor/src/portal/mocks/startIfEnabled.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readMocksPreference } from "@portal/mocks/preference"; - -/** - * Start the portal's MSW worker if the mocks preference is on. Await this before - * rendering PortalApp so the worker is registered before the first data fetch. - * The dynamic import keeps MSW and its fixtures out of chunks that don't run it. - */ -export async function startPortalMocksIfEnabled(): Promise { - if (!readMocksPreference()) return; - const { startMockWorker } = await import("@portal/mocks/browser"); - await startMockWorker(); -} diff --git a/frontend/editor/src/portal/mocks/users.ts b/frontend/editor/src/portal/mocks/users.ts index 95b2e2f25c..030a9046a7 100644 --- a/frontend/editor/src/portal/mocks/users.ts +++ b/frontend/editor/src/portal/mocks/users.ts @@ -13,221 +13,18 @@ */ import type { Tier } from "@portal/contexts/TierContext"; +import type { + AccessControls, + Member, + UsersResponse, + UsersSummary, +} from "@portal/api/users"; +import { ROLES } from "@portal/api/users"; /* ──────────────────────────────────────────────────────────────────────── */ /* Roles & members */ /* ──────────────────────────────────────────────────────────────────────── */ -/** 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, - "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. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const ROLES: Role[] = [ - { - id: "admin", - label: "Admin (Org owner)", - summary: "Full governance over the workspace, settings and members.", - permissions: [ - "Manage users, teams and roles", - "Manage all integrations incl. S3 connections", - "Grant or revoke portal access", - "Everything Team Owner can do", - ], - tone: "purple", - }, - { - id: "team_owner", - label: "Team owner", - summary: "Owns a team — manages its members' resources and shared configs.", - permissions: [ - "Create & manage the team's S3 connections", - "Manage team-owned integration configs", - "Portal access via the default policy", - "Everything Member can do", - ], - tone: "blue", - }, - { - id: "member", - label: "Member", - summary: - "Regular user — works with shared resources and their own configs.", - permissions: [ - "Use the editor and shared integrations", - "Create personal API & MCP configs", - "See team configs shared with them", - "No S3 or workspace management", - ], - tone: "green", - }, - { - id: "guest", - label: "Guest", - summary: "Limited or web-only access; cannot hold personal configs.", - permissions: [ - "Web-only / demo usage", - "No API keys or integrations", - "No portal access", - "Read-only where shared", - ], - 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; - /* ──────────────────────────────────────────────────────────────────────── */ /* Member fixtures */ /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/frontend/editor/src/portal/vite-env.d.ts b/frontend/editor/src/portal/vite-env.d.ts index 91acc858f2..894d70ddb3 100644 --- a/frontend/editor/src/portal/vite-env.d.ts +++ b/frontend/editor/src/portal/vite-env.d.ts @@ -11,8 +11,6 @@ interface ImportMetaEnv { readonly VITE_STRIPE_PUBLISHABLE_KEY: string; /** URL of the editor app (app switcher + non-admin redirect). See editor/.env.proprietary. */ readonly VITE_EDITOR_URL: string; - /** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */ - readonly VITE_PORTAL_MOCKS: string; } interface ImportMeta { diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index a2b361a86f..cffb02c28c 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -696,7 +696,7 @@ export function PolicySetupWizard({ : [...prev, dt], ) } - label={dt} + label={t(`policies.docType.${dt}`, dt)} /> ))}
diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index afb79c0a7f..7f60fbdd3b 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -9,16 +9,12 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // GHA when the portal or AI layers change). Vite replaces the env with a literal at // build time, so when it's off the dynamic import below is tree-shaken out and the // portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on -// each render. Mocks start first so the worker is ready before the portal's first -// fetch. +// each render. const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; const PortalApp = includePortal ? lazy(async () => { - const { startPortalMocksIfEnabled } = - await import("@portal/mocks/startIfEnabled"); - await startPortalMocksIfEnabled(); const m = await import("@portal/PortalApp"); return { default: m.PortalApp }; })