Remove in-app portal mocks (#6910)

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.
This commit is contained in:
Reece Browne
2026-07-09 14:50:50 +00:00
committed by GitHub
parent 22e8a82fa1
commit 2091874050
71 changed files with 2454 additions and 2302 deletions
+6 -4
View File
@@ -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
@@ -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"
@@ -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/<surface>.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,
@@ -11,7 +11,7 @@ import { PortalChrome } from "@portal/components/PortalChrome";
*/
export function PortalProviders() {
return (
<TierProvider initialTier="pro">
<TierProvider>
<UIProvider>
<PortalChrome />
</UIProvider>
+27 -21
View File
@@ -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/<surface>.ts ──► httpJson(fetch) ──► MSW handler (dev) ──► fixture builder
(the contract) └► real backend (prod) ─┘
view (useAsync) ──► api/<surface>.ts ──► httpJson(fetch) ──► real backend (app)
(the contract) └► MSW handler (Storybook/tests) ──► fixture builder
```
1. **`api/<surface>.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/<surface>.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/<surface>.ts`** — MSW handlers that answer those endpoints
with `mocks/<surface>.ts` fixtures. Registered in `mocks/handlers/index.ts`.
3. **`mocks/<surface>.ts`** — fixture builders **and the canonical TS types**
(re-exported through `api/<surface>.ts`, so consumers import types from `api/`).
3. **`mocks/<surface>.ts`** — fixture builders only, importing their types from
`api/<surface>.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/<surface>.ts` (the exported types are the spec).
4. Delete `mocks/` once parity is confirmed. Optionally relocate the types from
`mocks/<surface>.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/<surface>.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.
@@ -50,7 +50,7 @@ function LinkModalHost() {
export function PortalProviders() {
return (
<LinkProvider initialState="unlinked">
<TierProvider initialTier="pro">
<TierProvider>
<UIProvider>
<AccountLinkProvider>
<PortalChrome />
+117 -12
View File
@@ -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<AgentStatus, "success" | "neutral"> = {
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<AgentsResponse> {
@@ -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();
});
});
@@ -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<Response | undefined>;
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<void> {
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<Response | undefined> {
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);
}
+110 -14
View File
@@ -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<DocsNavSection[]> {
+126 -19
View File
@@ -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<DocumentStatus, string> = {
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<DocumentStatus, StatusTone> = {
processed: "success",
flagged: "warning",
"in-review": "purple",
error: "danger",
};
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
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<DocAuditKind, string> = {
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<DocAuditKind, StatusTone> = {
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<DocumentsResponse> {
+148 -18
View File
@@ -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<TargetKind, TargetMeta> = {
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<InstanceStatus, string> = {
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
+14
View File
@@ -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<T>(
path: string,
options: HttpRequestOptions = {},
): Promise<T> {
const demo = await resolveDemoResponse(
new URL(`${localBaseUrl()}${path}`, window.location.origin),
options,
);
if (demo) return unwrap<T>(demo);
const res = await fetch(`${localBaseUrl()}${path}`, {
method: options.method ?? "GET",
headers: {
@@ -214,6 +220,14 @@ async function saasJson<T>(
path: string,
options: HttpRequestOptions = {},
): Promise<T> {
// 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<T>(demo);
const base = saasBaseUrl();
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
if (base === null) throw new SaasUnconfiguredError();
+258 -44
View File
@@ -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 (01). */
load: number;
status: RegionStatus;
/** Deployed Stirling engine version. */
version: string;
/** 30-day uptime as a fraction (01). */
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 (01). */
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[];
+44 -15
View File
@@ -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";
@@ -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<Notification[]> {
+2 -3
View File
@@ -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. */
+391 -41
View File
@@ -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<string, boolean | string | string[]>;
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<string, boolean | string | string[]>;
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<string, string> = {
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<string, string> = {
"/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<string, PolicyConfigDef> = {
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(
+182 -21
View File
@@ -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(
+128 -18
View File
@@ -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>`. */
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<ComponentMaturity, MaturityMeta> = {
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<BillingUnit, string> = {
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<Tier, number> = { 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<ComponentsResponse> {
+6 -2
View File
@@ -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<QuickAction[]> {
+77 -9
View File
@@ -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<SettingsSnapshot> {
+208 -18
View File
@@ -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<RoleId, string> = Object.fromEntries(
ROLES.map((r) => [r.id, r.label]),
) as Record<RoleId, string>;
export const ROLE_TONE: Record<RoleId, Role["tone"]> = Object.fromEntries(
ROLES.map((r) => [r.id, r.tone]),
) as Record<RoleId, Role["tone"]>;
/** Roles an admin can assign from the portal; guest is derived, not assigned. */
export const ASSIGNABLE_ROLES: RoleId[] = ["admin", "team_owner", "member"];
@@ -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 (
<Dropdown.Root align="end">
@@ -143,7 +142,6 @@ export function Header() {
</Button>
<div className="portal-header__right">
<MocksToggle />
<ThemeToggle />
<NotificationsDropdown />
<TierSwitcher />
@@ -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;
}
@@ -1,55 +0,0 @@
/// <reference types="vite/client" />
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 (
<Button
variant="tertiary"
className={
"portal-mocks-toggle" +
(enabled ? " is-on" : " is-off") +
(pending ? " is-pending" : "")
}
onClick={toggle}
aria-pressed={enabled}
title={
enabled ? t("portal.mocks.tooltip.on") : t("portal.mocks.tooltip.off")
}
>
<span className="portal-mocks-toggle__dot" aria-hidden />
<span className="portal-mocks-toggle__label">
{enabled ? t("portal.mocks.label.on") : t("portal.mocks.label.off")}
</span>
</Button>
);
}
@@ -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";
@@ -44,7 +44,7 @@ export function ComponentCard({
<div className="portal-components__card-head">
<h3 className="portal-components__card-name">{component.name}</h3>
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
{maturity.label}
{t(maturity.label)}
</StatusBadge>
{!unlocked && (
<span
@@ -60,7 +60,7 @@ export function ComponentCard({
<div className="portal-components__card-meta">
<span className="portal-components__price">
{formatPrice(component.pricing)}
{formatPrice(component.pricing, t)}
</span>
<span className="portal-components__pkg">
@stirling/{component.package}
@@ -75,7 +75,7 @@ export function ComponentDetailModal({
<span className="portal-components__modal-title">
{component.name}
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
{maturity.label}
{t(maturity.label)}
</StatusBadge>
</span>
}
@@ -84,7 +84,7 @@ export function ComponentDetailModal({
unlocked ? (
<div className="portal-components__modal-footer">
<span className="portal-components__price">
{formatPrice(component.pricing)}
{formatPrice(component.pricing, t)}
</span>
<Button
size="sm"
@@ -155,11 +155,11 @@ export function ComponentDetailModal({
<div className="portal-components__stat-grid">
<StatTile
label={t("portal.catalogue.detail.stats.maturity")}
value={maturity.label}
value={t(maturity.label)}
/>
<StatTile
label={t("portal.catalogue.detail.stats.price")}
value={formatPrice(component.pricing)}
value={formatPrice(component.pricing, t)}
/>
<StatTile
label={t("portal.catalogue.detail.stats.freeQuota")}
@@ -201,7 +201,7 @@ export function ComponentDetailModal({
<div className="portal-components__stat-grid">
<StatTile
label={t("portal.catalogue.detail.stats.perAction")}
value={formatPrice(component.pricing)}
value={formatPrice(component.pricing, t)}
/>
<StatTile
label={t("portal.catalogue.detail.stats.billedOn")}
@@ -24,7 +24,7 @@ export function DocumentAudit({ doc }: { doc: ReviewDocument }) {
<div className="portal-documents__timeline-body">
<div className="portal-documents__timeline-head">
<StatusBadge tone={DOC_AUDIT_TONE[event.kind]} size="sm">
{DOC_AUDIT_LABEL[event.kind]}
{t(DOC_AUDIT_LABEL[event.kind])}
</StatusBadge>
<span className="portal-documents__timeline-time">
{event.time}
@@ -84,7 +84,7 @@ export function DocumentDrawer({ doc, onClose }: DocumentDrawerProps) {
<div className="portal-documents__drawer">
<div className="portal-documents__drawer-status">
<StatusBadge tone={DOCUMENT_STATUS_TONE[doc.status]} size="sm">
{DOCUMENT_STATUS_LABEL[doc.status]}
{t(DOCUMENT_STATUS_LABEL[doc.status])}
</StatusBadge>
</div>
@@ -13,7 +13,7 @@ export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
<div className="portal-documents__stat-grid">
<StatTile
label={t("portal.documents.overview.status")}
value={DOCUMENT_STATUS_LABEL[doc.status]}
value={t(DOCUMENT_STATUS_LABEL[doc.status])}
/>
<StatTile
label={t("portal.documents.overview.product")}
@@ -120,7 +120,7 @@ export function ReviewQueueTable({
width: "10rem",
render: (d) => (
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
{DOCUMENT_STATUS_LABEL[d.status]}
{t(DOCUMENT_STATUS_LABEL[d.status])}
{d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""}
</StatusBadge>
),
@@ -73,7 +73,7 @@ export function InstanceHealthTable({ instances }: Props) {
size="sm"
pulse={i.status === "healthy"}
>
{INSTANCE_STATUS_LABEL[i.status]}
{t(INSTANCE_STATUS_LABEL[i.status])}
</StatusBadge>
),
},
@@ -15,7 +15,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
const comingSoon = category.comingSoon === true;
const openable = !comingSoon;
const status = policy?.state.status;
const enforces = config.rules.join(" · ");
const enforces = config.rules.map((r) => t(r)).join(" · ");
return (
<Card
@@ -43,7 +43,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
</span>
<div className="portal-policies__card-identity">
<h2 className="portal-policies__card-title">{category.label}</h2>
<h2 className="portal-policies__card-title">{t(category.label)}</h2>
{enforces && (
<span className="portal-policies__card-enforces">{enforces}</span>
)}
@@ -140,7 +140,7 @@ export function PolicyDetailPanel({
open
onClose={onClose}
width="lg"
title={category.label}
title={t(category.label)}
footer={
<div className="portal-policies__detail-foot">
{canDelete && (
@@ -231,10 +231,10 @@ export function PolicyDetailPanel({
{" "}
</span>
)}
{humanizeEndpoint(op)}
{humanizeEndpoint(op, t)}
</span>
))
: config.rules.join(" · ")}
: config.rules.map((r) => t(r)).join(" · ")}
</span>
</div>
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Chip, FormField, Input, Select, ToggleSwitch } from "@app/ui";
import type { PolicyField } from "@portal/api/policies";
import "@portal/views/Policies.css";
@@ -19,13 +20,14 @@ export function PolicyFieldRow({
value,
onChange,
}: PolicyFieldRowProps) {
const { t } = useTranslation();
if (field.type === "toggle") {
return (
<div className="portal-policies__toggle-row">
<ToggleSwitch
checked={Boolean(value)}
onChange={onChange}
label={field.label}
label={t(field.label)}
/>
</div>
);
@@ -40,7 +42,7 @@ export function PolicyFieldRow({
: [...selected, opt],
);
return (
<FormField label={field.label}>
<FormField label={t(field.label)}>
<div className="portal-policies__field-chips">
{(field.options ?? []).map((opt) => (
<Chip
@@ -49,7 +51,7 @@ export function PolicyFieldRow({
size="sm"
onClick={() => toggle(opt)}
>
{opt}
{t(`policies.fieldOption.${field.key}.${opt}`, opt)}
</Chip>
))}
</div>
@@ -59,11 +61,14 @@ export function PolicyFieldRow({
if (field.type === "select") {
return (
<FormField label={field.label}>
<FormField label={t(field.label)}>
<Select
inputSize="sm"
value={typeof value === "string" ? value : ""}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
options={(field.options ?? []).map((o) => ({
value: o,
label: t(`policies.fieldOption.${field.key}.${o}`, o),
}))}
onChange={(value) => onChange(value ?? "")}
/>
</FormField>
@@ -71,7 +76,7 @@ export function PolicyFieldRow({
}
return (
<FormField label={field.label}>
<FormField label={t(field.label)}>
<Input
inputSize="sm"
value={typeof value === "string" ? value : ""}
@@ -241,14 +241,14 @@ function PolicySetupWizardBody({
</span>
{isEdit
? t("portal.policies.wizard.title.edit", {
category: category.label,
category: t(category.label),
})
: t("portal.policies.wizard.title.setUp", {
category: category.label,
category: t(category.label),
})}
</span>
}
subtitle={config.summary}
subtitle={t(config.summary)}
footer={
<div className="portal-policies__wizard-foot">
<Button variant="tertiary" size="sm" onClick={onClose}>
@@ -310,7 +310,7 @@ function PolicySetupWizardBody({
<Card key={tl.operation} padding="tight">
<div className="portal-policies__tool-head">
<span className="portal-policies__tool-name">
{humanizeEndpoint(tl.operation)}
{humanizeEndpoint(tl.operation, t)}
</span>
<span style={{ flex: 1 }} />
<ToggleSwitch
@@ -434,7 +434,7 @@ function PolicySetupWizardBody({
size="sm"
onClick={() => toggleScopeType(dt)}
>
{dt}
{t(`policies.docType.${dt}`, dt)}
</Chip>
))}
</div>
@@ -8,11 +8,10 @@ import { runsToActivity, runsToStats } from "@app/policies/runs";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
seedPolicies,
seedPolicyRuns,
type DecoratedPolicy,
type PolicyState,
} from "@portal/mocks/policies";
} from "@portal/api/policies";
import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies";
export { POLICY_CATEGORIES, POLICY_CONFIG };
@@ -79,7 +79,7 @@ export function DealJourney({
{isTerminal
? t("portal.procurement.journey.live")
: t("portal.procurement.journey.nextStep", {
action: currentStep?.gatingAction ?? "",
action: currentStep ? t(currentStep.gatingAction) : "",
})}
</span>
</div>
@@ -90,7 +90,7 @@ export function DealJourney({
loading={advancing}
onClick={() => onAdvance(currentStage)}
>
{currentStep.gatingAction}
{t(currentStep.gatingAction)}
</Button>
)}
</div>
@@ -78,7 +78,9 @@ export function DocumentLedger({
{group.label}
</span>
{blurb && (
<span className="portal-proc__stage-hint">· {blurb}</span>
<span className="portal-proc__stage-hint">
· {t(blurb)}
</span>
)}
{cur && (
<Chip accent="premium" size="sm">
@@ -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<typeof LockedState> = {
@@ -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<typeof StageStepper> = {
@@ -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({
)}
<div className={`portal-proc__step portal-proc__step--${state}`}>
<span className="portal-proc__step-dot" aria-hidden />
<span className="portal-proc__step-label">{step.label}</span>
<span className="portal-proc__step-label">{t(step.label)}</span>
</div>
</Fragment>
);
@@ -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") },
@@ -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<Tier, TierInfo> = {
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<TierContextValue | null>(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<Tier>(initialTier ?? "free");
const derivedTier = usePlanTier();
const [mockTier, setMockTier] = useState<Tier>(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<TierContextValue>(
() => ({
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 <TierContext.Provider value={value}>{children}</TierContext.Provider>;
+3 -114
View File
@@ -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<AgentStatus, "success" | "neutral"> = {
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 */
@@ -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<void> {
if (workerStarted) return;
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: { url: "/mockServiceWorker.js" },
quiet: true,
});
workerStarted = true;
}
+13 -100
View File
@@ -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",
+9 -125
View File
@@ -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<DocumentStatus, string> = {
processed: "Processed",
flagged: "Needs Review",
"in-review": "In Review",
error: "Error",
};
export const DOCUMENT_STATUS_TONE: Record<DocumentStatus, StatusTone> = {
processed: "success",
flagged: "warning",
"in-review": "purple",
error: "danger",
};
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
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<DocAuditKind, string> = {
ingested: "Ingested",
extracted: "Processed",
flagged: "Needs Review",
reviewed: "In Review",
approved: "Approved",
archived: "Archived",
elevation: "Elevation",
};
export const DOC_AUDIT_TONE: Record<DocAuditKind, StatusTone> = {
ingested: "info",
extracted: "success",
flagged: "warning",
reviewed: "purple",
approved: "success",
archived: "neutral",
elevation: "purple",
};
/* ──────────────────────────────────────────────────────────────────────── */
/* Fixture builder */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -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<TargetKind, TargetMeta> = {
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<InstanceStatus, string> = {
healthy: "Healthy",
degraded: "Degraded",
offline: "Offline",
pairing: "Pairing",
};
import type {
DeploymentSummary,
DeploymentTarget,
EditorDeploymentResponse,
EditorInstance,
PairingOption,
} from "@portal/api/editorDeploy";
/* ──────────────────────────────────────────────────────────────────────── */
/* Snippet builders */
@@ -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";
@@ -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";
/**
@@ -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];
@@ -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
@@ -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,
@@ -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 (01). */
load: number;
status: RegionStatus;
/** Deployed Stirling engine version. */
version: string;
/** 30-day uptime as a fraction (01). */
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 (01). */
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",
+11 -54
View File
@@ -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 */
@@ -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[] = [
{
+5 -356
View File
@@ -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<string, boolean | string | string[]>;
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<string, boolean | string | string[]>;
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<string, string> = {
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<string, string> = {
"/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<string, PolicyConfigDef> = {
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 */
@@ -1,31 +0,0 @@
/// <reference types="vite/client" />
/**
* 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));
}
+13 -186
View File
@@ -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 */
@@ -17,7 +17,7 @@ import {
type LedgerDoc,
type LedgerGroup,
type SupportingGroup,
} from "@portal/mocks/procurement";
} from "@portal/api/procurement";
export interface ProcurementStore {
deal: Deal;
+10 -119
View File
@@ -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>`. */
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<ComponentMaturity, MaturityMeta> = {
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<BillingUnit, string> = {
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<Tier, number> = { 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));
+6 -7
View File
@@ -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" },
+10 -76
View File
@@ -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)" },
@@ -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<void> {
if (!readMocksPreference()) return;
const { startMockWorker } = await import("@portal/mocks/browser");
await startMockWorker();
}
+7 -210
View File
@@ -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<RoleId, string> = Object.fromEntries(
ROLES.map((r) => [r.id, r.label]),
) as Record<RoleId, string>;
export const ROLE_TONE: Record<RoleId, Role["tone"]> = Object.fromEntries(
ROLES.map((r) => [r.id, r.tone]),
) as Record<RoleId, Role["tone"]>;
/* ──────────────────────────────────────────────────────────────────────── */
/* Member fixtures */
/* ──────────────────────────────────────────────────────────────────────── */
-2
View File
@@ -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 {
@@ -696,7 +696,7 @@ export function PolicySetupWizard({
: [...prev, dt],
)
}
label={dt}
label={t(`policies.docType.${dt}`, dt)}
/>
))}
</div>
@@ -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 };
})