mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,50 +1,264 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
AuditLogResponse,
|
||||
DeploymentRegion,
|
||||
ModelsResponse,
|
||||
RecentDeployment,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
} from "@portal/mocks/infrastructure";
|
||||
|
||||
export type {
|
||||
AccessPolicy,
|
||||
ApiKey,
|
||||
ApiKeyPermission,
|
||||
ApiKeyStatus,
|
||||
AttestationStatus,
|
||||
AuditCategory,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditStatus,
|
||||
AuditSummary,
|
||||
CertStatus,
|
||||
ComplianceAttestation,
|
||||
ComplianceCert,
|
||||
DataResidency,
|
||||
DeploymentRegion,
|
||||
DeploymentStatus,
|
||||
IpAllowEntry,
|
||||
KeyManagement,
|
||||
KeyMode,
|
||||
ModelCostUnit,
|
||||
ModelEntry,
|
||||
ModelProvider,
|
||||
ModelsResponse,
|
||||
ModelsSummary,
|
||||
ModelStatus,
|
||||
ModelType,
|
||||
RecentDeployment,
|
||||
RegionStatus,
|
||||
RetentionWindow,
|
||||
RoutingRule,
|
||||
SecurityConfig,
|
||||
StorageConfig,
|
||||
StorageProvider,
|
||||
} from "@portal/mocks/infrastructure";
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deployments */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RegionStatus = "healthy" | "degraded" | "down";
|
||||
|
||||
export interface DeploymentRegion {
|
||||
name: string;
|
||||
code: string;
|
||||
/** Median request latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Current load as a fraction of provisioned capacity (0–1). */
|
||||
load: number;
|
||||
status: RegionStatus;
|
||||
/** Deployed Stirling engine version. */
|
||||
version: string;
|
||||
/** 30-day uptime as a fraction (0–1). */
|
||||
uptime: number;
|
||||
/** Running instance count. */
|
||||
instances: number;
|
||||
/** Sustained throughput, docs/min. */
|
||||
throughput: number;
|
||||
/** P99 latency, ms. */
|
||||
p99Ms: number;
|
||||
}
|
||||
|
||||
export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued";
|
||||
|
||||
export interface RecentDeployment {
|
||||
id: string;
|
||||
version: string;
|
||||
environment: "production" | "staging" | "canary";
|
||||
product: string;
|
||||
status: DeploymentStatus;
|
||||
deployedBy: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
usageToday: number;
|
||||
usageMonth: number;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Security */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AccessPolicy = "stirling" | "byok" | "hyok";
|
||||
export type DataResidency = "us" | "eu" | "apac";
|
||||
export type CertStatus = "certified" | "in-progress" | "not-started";
|
||||
|
||||
export interface ComplianceCert {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CertStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface IpAllowEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
cidr: string;
|
||||
addedBy: string;
|
||||
added: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is
|
||||
* surfaced separately because the key *custody model* (who can decrypt) is the
|
||||
* detail security teams scrutinise:
|
||||
* - `managed` — Stirling-owned KMS keys; zero key ops on the customer side.
|
||||
* - `byok` — customer key, but Stirling can use it to decrypt while processing.
|
||||
* - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext.
|
||||
*/
|
||||
export type KeyMode = "managed" | "byok" | "hyok";
|
||||
|
||||
export interface KeyManagement {
|
||||
mode: KeyMode;
|
||||
/** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */
|
||||
provider: string;
|
||||
/** ARN-style identifier for the active key. */
|
||||
keyId: string;
|
||||
/** Encryption algorithm in force. */
|
||||
algorithm: string;
|
||||
/** Relative last-rotation time, e.g. "32 days ago". */
|
||||
lastRotated: string;
|
||||
/** Rotation cadence summary, e.g. "Automatic · every 90 days". */
|
||||
rotationPolicy: string;
|
||||
/**
|
||||
* Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed
|
||||
* tiers see the posture but cannot change provider — only enterprise can.
|
||||
*/
|
||||
customerManaged: boolean;
|
||||
}
|
||||
|
||||
export type AttestationStatus = "attested" | "in-scope" | "not-applicable";
|
||||
|
||||
export interface ComplianceAttestation {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Framework family / short descriptor shown under the name. */
|
||||
framework: string;
|
||||
status: AttestationStatus;
|
||||
/** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */
|
||||
detail: string;
|
||||
/** Stub link to the downloadable report; null when none is available. */
|
||||
reportUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
accessPolicy: AccessPolicy;
|
||||
dataResidency: DataResidency;
|
||||
certs: ComplianceCert[];
|
||||
ipAllowlist: IpAllowEntry[];
|
||||
keyManagement: KeyManagement;
|
||||
attestations: ComplianceAttestation[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Storage */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RetentionWindow = "30" | "60" | "90" | "180" | "never";
|
||||
|
||||
export interface StorageProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "stirling" | "s3" | "azure";
|
||||
connected: boolean;
|
||||
detail: string;
|
||||
usedGb: number;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
/** Total used storage, GB. */
|
||||
usedGb: number;
|
||||
/** Quota ceiling, GB. */
|
||||
quotaGb: number;
|
||||
retention: RetentionWindow;
|
||||
providers: StorageProvider[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Audit Logs */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AuditCategory =
|
||||
| "auth"
|
||||
| "config"
|
||||
| "elevation"
|
||||
| "processing"
|
||||
| "security";
|
||||
|
||||
export type AuditStatus = "success" | "warning" | "danger" | "info";
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
category: AuditCategory;
|
||||
action: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
status: AuditStatus;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
totalEvents: number;
|
||||
processing: number;
|
||||
elevation: number;
|
||||
config: number;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
summary: AuditSummary;
|
||||
events: AuditEvent[];
|
||||
/** True for the whole-server (admin) view; gates the admin-only CSV export. */
|
||||
fullServer: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Models */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem";
|
||||
export type ModelType = "extraction" | "classification" | "ocr" | "llm";
|
||||
export type ModelStatus = "active" | "degraded" | "disabled";
|
||||
|
||||
/** Whether a model's cost is billed per 1k documents or per individual call. */
|
||||
export type ModelCostUnit = "per-1k-docs" | "per-call";
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
type: ModelType;
|
||||
status: ModelStatus;
|
||||
/** Median inference latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Cost in USD for the model's billing unit (see {@link costUnit}). */
|
||||
cost: number;
|
||||
costUnit: ModelCostUnit;
|
||||
version: string;
|
||||
/** Share of capacity this model is currently absorbing (0–1). */
|
||||
load: number;
|
||||
/** True for customer-registered bring-your-own / on-prem models. */
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
/** A binding from a processing operation (optionally a doc-type) to a model. */
|
||||
export interface RoutingRule {
|
||||
id: string;
|
||||
/** The operation or pipeline stage this rule governs. */
|
||||
operation: string;
|
||||
/** Doc-type scope, or "All document types" for a catch-all. */
|
||||
docType: string;
|
||||
/** id of the {@link ModelEntry} this operation routes to. */
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
/** Marks the fallback rule applied when no narrower rule matches. */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsSummary {
|
||||
activeModels: number;
|
||||
/** Capacity-weighted average latency across active models, ms. */
|
||||
avgLatencyMs: number;
|
||||
/** Projected monthly model spend, USD. */
|
||||
monthlySpend: number;
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
summary: ModelsSummary;
|
||||
models: ModelEntry[];
|
||||
routing: RoutingRule[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DeploymentsResponse {
|
||||
regions: DeploymentRegion[];
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (0–1). */
|
||||
load: number;
|
||||
status: RegionStatus;
|
||||
/** Deployed Stirling engine version. */
|
||||
version: string;
|
||||
/** 30-day uptime as a fraction (0–1). */
|
||||
uptime: number;
|
||||
/** Running instance count. */
|
||||
instances: number;
|
||||
/** Sustained throughput, docs/min. */
|
||||
throughput: number;
|
||||
/** P99 latency, ms. */
|
||||
p99Ms: number;
|
||||
}
|
||||
|
||||
export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued";
|
||||
|
||||
export interface RecentDeployment {
|
||||
id: string;
|
||||
version: string;
|
||||
environment: "production" | "staging" | "canary";
|
||||
product: string;
|
||||
status: DeploymentStatus;
|
||||
deployedBy: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const REGION_US_EAST: DeploymentRegion = {
|
||||
name: "US East (N. Virginia)",
|
||||
code: "us-east-1",
|
||||
@@ -178,25 +162,6 @@ export function recentDeploymentsFor(tier: Tier): RecentDeployment[] {
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
usageToday: number;
|
||||
usageMonth: number;
|
||||
}
|
||||
|
||||
const API_KEYS_ALL: ApiKey[] = [
|
||||
{
|
||||
id: "key-1",
|
||||
@@ -262,77 +227,6 @@ export function apiKeysFor(tier: Tier): ApiKey[] {
|
||||
/* Security */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AccessPolicy = "stirling" | "byok" | "hyok";
|
||||
export type DataResidency = "us" | "eu" | "apac";
|
||||
export type CertStatus = "certified" | "in-progress" | "not-started";
|
||||
|
||||
export interface ComplianceCert {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CertStatus;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface IpAllowEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
cidr: string;
|
||||
addedBy: string;
|
||||
added: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is
|
||||
* surfaced separately because the key *custody model* (who can decrypt) is the
|
||||
* detail security teams scrutinise:
|
||||
* - `managed` — Stirling-owned KMS keys; zero key ops on the customer side.
|
||||
* - `byok` — customer key, but Stirling can use it to decrypt while processing.
|
||||
* - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext.
|
||||
*/
|
||||
export type KeyMode = "managed" | "byok" | "hyok";
|
||||
|
||||
export interface KeyManagement {
|
||||
mode: KeyMode;
|
||||
/** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */
|
||||
provider: string;
|
||||
/** ARN-style identifier for the active key. */
|
||||
keyId: string;
|
||||
/** Encryption algorithm in force. */
|
||||
algorithm: string;
|
||||
/** Relative last-rotation time, e.g. "32 days ago". */
|
||||
lastRotated: string;
|
||||
/** Rotation cadence summary, e.g. "Automatic · every 90 days". */
|
||||
rotationPolicy: string;
|
||||
/**
|
||||
* Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed
|
||||
* tiers see the posture but cannot change provider — only enterprise can.
|
||||
*/
|
||||
customerManaged: boolean;
|
||||
}
|
||||
|
||||
export type AttestationStatus = "attested" | "in-scope" | "not-applicable";
|
||||
|
||||
export interface ComplianceAttestation {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Framework family / short descriptor shown under the name. */
|
||||
framework: string;
|
||||
status: AttestationStatus;
|
||||
/** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */
|
||||
detail: string;
|
||||
/** Stub link to the downloadable report; null when none is available. */
|
||||
reportUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityConfig {
|
||||
accessPolicy: AccessPolicy;
|
||||
dataResidency: DataResidency;
|
||||
certs: ComplianceCert[];
|
||||
ipAllowlist: IpAllowEntry[];
|
||||
keyManagement: KeyManagement;
|
||||
attestations: ComplianceAttestation[];
|
||||
}
|
||||
|
||||
const CERTS_FULL: ComplianceCert[] = [
|
||||
{
|
||||
id: "soc2",
|
||||
@@ -559,26 +453,6 @@ export function securityFor(tier: Tier): SecurityConfig {
|
||||
/* Storage */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type RetentionWindow = "30" | "60" | "90" | "180" | "never";
|
||||
|
||||
export interface StorageProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "stirling" | "s3" | "azure";
|
||||
connected: boolean;
|
||||
detail: string;
|
||||
usedGb: number;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
/** Total used storage, GB. */
|
||||
usedGb: number;
|
||||
/** Quota ceiling, GB. */
|
||||
quotaGb: number;
|
||||
retention: RetentionWindow;
|
||||
providers: StorageProvider[];
|
||||
}
|
||||
|
||||
const PROVIDERS_FULL: StorageProvider[] = [
|
||||
{
|
||||
id: "stirling",
|
||||
@@ -635,26 +509,6 @@ export function storageFor(tier: Tier): StorageConfig {
|
||||
/* Audit Logs */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AuditCategory =
|
||||
| "auth"
|
||||
| "config"
|
||||
| "elevation"
|
||||
| "processing"
|
||||
| "security";
|
||||
|
||||
export type AuditStatus = "success" | "warning" | "danger" | "info";
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
category: AuditCategory;
|
||||
action: string;
|
||||
actor: string;
|
||||
target: string;
|
||||
status: AuditStatus;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
// Mirrors what the real backend (PortalInfraAuditService) returns: audit_events
|
||||
// mapped from real AuditEventType values to the tab's categories. Only real
|
||||
// types appear - there is no audited "elevation" event, so that category is
|
||||
@@ -842,77 +696,10 @@ const AUDIT_EVENTS_ALL: AuditEvent[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export interface AuditSummary {
|
||||
totalEvents: number;
|
||||
processing: number;
|
||||
elevation: number;
|
||||
config: number;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
summary: AuditSummary;
|
||||
events: AuditEvent[];
|
||||
/** True for the whole-server (admin) view; gates the admin-only CSV export. */
|
||||
fullServer: boolean;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Models */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem";
|
||||
export type ModelType = "extraction" | "classification" | "ocr" | "llm";
|
||||
export type ModelStatus = "active" | "degraded" | "disabled";
|
||||
|
||||
/** Whether a model's cost is billed per 1k documents or per individual call. */
|
||||
export type ModelCostUnit = "per-1k-docs" | "per-call";
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: ModelProvider;
|
||||
type: ModelType;
|
||||
status: ModelStatus;
|
||||
/** Median inference latency, ms. */
|
||||
latencyMs: number;
|
||||
/** Cost in USD for the model's billing unit (see {@link costUnit}). */
|
||||
cost: number;
|
||||
costUnit: ModelCostUnit;
|
||||
version: string;
|
||||
/** Share of capacity this model is currently absorbing (0–1). */
|
||||
load: number;
|
||||
/** True for customer-registered bring-your-own / on-prem models. */
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
/** A binding from a processing operation (optionally a doc-type) to a model. */
|
||||
export interface RoutingRule {
|
||||
id: string;
|
||||
/** The operation or pipeline stage this rule governs. */
|
||||
operation: string;
|
||||
/** Doc-type scope, or "All document types" for a catch-all. */
|
||||
docType: string;
|
||||
/** id of the {@link ModelEntry} this operation routes to. */
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
/** Marks the fallback rule applied when no narrower rule matches. */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsSummary {
|
||||
activeModels: number;
|
||||
/** Capacity-weighted average latency across active models, ms. */
|
||||
avgLatencyMs: number;
|
||||
/** Projected monthly model spend, USD. */
|
||||
monthlySpend: number;
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
summary: ModelsSummary;
|
||||
models: ModelEntry[];
|
||||
routing: RoutingRule[];
|
||||
}
|
||||
|
||||
const MODELS_ALL: ModelEntry[] = [
|
||||
{
|
||||
id: "m-extract-v3",
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user