Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696)

Builds the remaining developer-portal surfaces from the unified design
and rebuilds Settings, on top of the portal scaffold merged in #6686.
All tier-aware, mock-driven (MSW), componentised with Storybook
coverage. Touches only `frontend/portal` + `frontend/shared` — the
editor is untouched.

## New surfaces
- **Policies** — org-wide governance across the five categories
(Ingestion / Security / Compliance / Routing / Retention) with a
designer + per-doc-type overrides
- **Users** — members, roles, invite, tier-scaled SSO/SCIM access
- **Components** — embeddable `@stirling/*` SDK catalogue with
per-action pricing
- **Getting Started** — three-step funnel (use case → analyse a document
→ API key + snippets)
- **Agent Builder** — agent lifecycle (scenarios, tool modes,
evals/golden-sets, versions), reached from Sources
- **Editor deployment** — deploy/pair/operate the editor (targets,
pairing, health, credential rotation, air-gapped bundle), reached from
Infrastructure

## Reworks
- **Documents** → review/approval queue (confidence, extractions, audit
drawer, zero-standing-access elevation); the doc-type catalogue is
retained as a second tab
- **Pipelines** → golden-set pass column + "Promoted from the Editor"
section
- **Infrastructure** → new **Models** tab; deeper **Security** (managed
/ BYOK / HYOK + SOC 2 / ISO 27001 / HIPAA / GDPR / PCI attestations)
- **Home** → "What runs on your PDFs" policy summary + tier-aware
processing-status strip + pipeline-fork wizard

## Settings & shared
- New shared **`SettingsShell`** (grouped left-nav + content pane),
modelled on the editor's account-settings modal so both apps can
converge on one layout
- Portal **Settings** rebuilt on it as scoped sections — Account /
Workspace / Admin (Authentication, Active sessions, Early access)

## Brand
- Adopt the editor's brand mark + favicon; sidebar reads **Stirling
Processor**; app-switcher labels the active app "Processor"

## Mock contract
- Every surface follows the 3-layer pattern (typed `api/*` → MSW handler
→ fixtures); new endpoints documented in `MOCKS.md`. The read contract
is backend-ready; writes are marked `// TODO(backend): <METHOD> <path>`.

## Verification
- tsc (portal + shared) ✓ · eslint ✓ · dpdm (no circular) ✓ ·
`build:portal` ✓ · `storybook:build` ✓ · Prettier ✓

## Deferred (noted, not in scope)
- Unified shell / auth / role→surface routing / Workspace=Plan
(architectural epic)
- Tier rename (Editor / Processor / Bespoke) and the Usage flat-pricing
+ PAYG quick-amounts + Bespoke modal
- Editor adopting the shared `SettingsShell`; converting marked
write-stubs into live `api/` seams
This commit is contained in:
Reece Browne
2026-06-18 10:28:03 +00:00
committed by GitHub
parent 0c503cc41d
commit 2b05865a84
167 changed files with 14365 additions and 699 deletions
+12 -2
View File
@@ -53,12 +53,17 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
| Home | `GET /v1/activity` | — | `fetchRecentActivity` | `ActivityEvent[]` |
| Home | `GET /v1/regions/health` | — | `fetchRegionHealth` | `RegionHealth[]` |
| Home | `GET /v1/onboarding` | — | `fetchOnboarding` | `OnboardingStep[]` |
| Documents | `GET /v1/endpoints` | `vertical?` | `fetchVerticals` | `Vertical[]` |
| Pipelines | `GET /v1/pipelines` | `tier` | `fetchPipelines` | `PipelinesResponse` |
| Users | `GET /v1/users` | `tier` | `fetchUsers` | `UsersResponse` |
| Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` |
| Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` |
| Policies | `GET/POST /api/v1/policies` · `GET/DELETE /api/v1/policies/{id}` · `POST /api/v1/policies/{id}/run` | — | `fetchPolicies` · `savePolicy` · `deletePolicy` · `runPolicy` | `PoliciesResponse` · `Policy` |
| Agent Builder | `GET /v1/agents` | `tier` | `fetchAgents` | `AgentsResponse` |
| Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` |
| Components | `GET /v1/components` | `tier` | `fetchComponents` | `ComponentsResponse` |
| Infrastructure | `GET /v1/infrastructure/deployments` | `tier` | `fetchDeployments` | `DeploymentsResponse` |
| Infrastructure | `GET /v1/infrastructure/api-keys` | `tier` | `fetchApiKeys` | `ApiKey[]` |
| Infrastructure | `GET /v1/infrastructure/security` | `tier` | `fetchSecurity` | `SecurityConfig` |
| Infrastructure | `GET /v1/infrastructure/models` | `tier` | `fetchModels` | `ModelsResponse` |
| Infrastructure | `GET /v1/infrastructure/storage` | `tier` | `fetchStorage` | `StorageConfig` |
| Infrastructure | `GET /v1/infrastructure/audit-log` | `tier` | `fetchAuditLog` | `AuditLogResponse` |
| Usage & Billing | `GET /v1/billing/usage` | — | `fetchBillingUsage` | `UsageSeriesResponse` |
@@ -66,6 +71,7 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
| Usage & Billing | `GET /v1/billing/plans` | — | `fetchPlanOptions` | `PlanOption[]` |
| Usage & Billing | `GET /v1/billing/history` | `tier` | `fetchBillingHistory` | `BillingHistoryRow[]` |
| Developer Docs | `GET /v1/docs/nav` | — | `fetchDocsNav` | `DocsNavSection[]` |
| Editor | `GET /v1/editor/deployment` | `tier` | `fetchEditorDeployment` | `EditorDeploymentResponse` |
| Settings | `GET /v1/settings` | `tier` | `fetchSettings` | `UserSettings` |
| Notifications | `GET /v1/notifications` · `POST /v1/notifications/mark-all-read` | — | — | `Notification[]` |
| Ops | `GET /v1/ops/featured` · `POST /v1/ops/:opId/run` | — | — | — |
@@ -74,3 +80,7 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
> Catalogue generated from `mocks/handlers/*.ts`. The `api/<surface>.ts` JSDoc on
> each function is the authoritative per-endpoint reference.
>
> **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.
+3 -2
View File
@@ -4,8 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="Stirling developer portal" />
<title>Stirling — Developer Portal</title>
<meta name="description" content="Stirling Processor" />
<link rel="icon" href="/favicon.ico" />
<title>Stirling Processor</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10
View File
@@ -1,8 +1,13 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Home } from "@portal/views/Home";
import { Users } from "@portal/views/Users";
import { Documents } from "@portal/views/Documents";
import { Pipelines } from "@portal/views/Pipelines";
import { Sources } from "@portal/views/Sources";
import { AgentBuilder } from "@portal/views/AgentBuilder";
import { Policies } from "@portal/views/Policies";
import { Components } from "@portal/views/Components";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
import { Usage } from "@portal/views/Usage";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
@@ -12,9 +17,14 @@ export function ViewRouter() {
return (
<Routes>
<Route path={VIEW_PATHS.home} element={<Home />} />
<Route path={VIEW_PATHS.users} element={<Users />} />
<Route path={VIEW_PATHS.pipelines} element={<Pipelines />} />
<Route path={VIEW_PATHS.sources} element={<Sources />} />
<Route path={VIEW_PATHS["agent-builder"]} element={<AgentBuilder />} />
<Route path={VIEW_PATHS.policies} element={<Policies />} />
<Route path={VIEW_PATHS.documents} element={<Documents />} />
<Route path={VIEW_PATHS.components} element={<Components />} />
<Route path={VIEW_PATHS.editor} element={<EditorAdmin />} />
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
<Route path={VIEW_PATHS.usage} element={<Usage />} />
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
+22
View File
@@ -0,0 +1,22 @@
import { httpJson } 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";
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
return httpJson<AgentsResponse>(
`/v1/agents?tier=${encodeURIComponent(tier)}`,
);
}
+26
View File
@@ -0,0 +1,26 @@
import { httpJson } from "@portal/api/http";
import type { DocumentsResponse } from "@portal/mocks/documents";
import type { Tier } from "@portal/contexts/TierContext";
export type {
DocAuditEvent,
DocAuditKind,
DocumentStatus,
DocumentsResponse,
DocumentsSummary,
Extraction,
ReviewDocument,
} from "@portal/mocks/documents";
export {
DOC_AUDIT_LABEL,
DOC_AUDIT_TONE,
DOCUMENT_STATUS_LABEL,
DOCUMENT_STATUS_TONE,
} from "@portal/mocks/documents";
/** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
return httpJson<DocumentsResponse>(
`/v1/documents?tier=${encodeURIComponent(tier)}`,
);
}
+35
View File
@@ -0,0 +1,35 @@
import { httpJson } 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,
InstanceStatus,
PairingMethod,
PairingOption,
TargetKind,
TargetMeta,
TargetState,
} from "@portal/mocks/editorDeploy";
export {
INSTANCE_STATUS_LABEL,
INSTANCE_STATUS_TONE,
TARGET_META,
} from "@portal/mocks/editorDeploy";
/**
* GET /v1/editor/deployment?tier=… — the org's Editor deployment: summary
* metric strip, deployment targets (with run snippets), pairing options, and
* the live instance health table for the tier.
*/
export async function fetchEditorDeployment(
tier: Tier,
): Promise<EditorDeploymentResponse> {
return httpJson<EditorDeploymentResponse>(
`/v1/editor/deployment?tier=${encodeURIComponent(tier)}`,
);
}
-15
View File
@@ -1,15 +0,0 @@
import { httpJson } from "@portal/api/http";
import type { Vertical } from "@shared/data/endpoints";
export type {
Endpoint,
EndpointSchema,
EndpointTierGate,
Vertical,
VerticalKey,
} from "@shared/data/endpoints";
/** GET /v1/endpoints — verticals plus their endpoints. */
export async function fetchVerticals(): Promise<Vertical[]> {
return httpJson<Vertical[]>("/v1/endpoints");
}
+3
View File
@@ -13,10 +13,13 @@ export type {
ActivityKind,
KpiEntry,
OnboardingStep,
PipelineStage,
PipelineTemplate,
RegionHealth,
UsagePoint,
UsageSeriesResponse,
} from "@portal/mocks/home";
export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home";
/** GET /v1/analytics/usage?window=30d */
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
+18
View File
@@ -4,6 +4,7 @@ import type {
ApiKey,
AuditLogResponse,
DeploymentRegion,
ModelsResponse,
RecentDeployment,
SecurityConfig,
StorageConfig,
@@ -14,20 +15,32 @@ export type {
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,
@@ -59,6 +72,11 @@ export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
return httpJson<SecurityConfig>(`/v1/infrastructure/security${q(tier)}`);
}
/** GET /v1/infrastructure/models?tier=… */
export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
return httpJson<ModelsResponse>(`/v1/infrastructure/models${q(tier)}`);
}
/** GET /v1/infrastructure/storage?tier=… */
export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
return httpJson<StorageConfig>(`/v1/infrastructure/storage${q(tier)}`);
+17
View File
@@ -9,6 +9,8 @@ export type {
PipelineMetrics,
PipelinesResponse,
PipelineStatus,
PromotedPipeline,
PromotedStatus,
SchemaDrift,
StageKey,
StageSummary,
@@ -20,3 +22,18 @@ export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
`/v1/pipelines?tier=${encodeURIComponent(tier)}`,
);
}
/**
* Promote a watch-folder-derived pipeline into a governed org policy, so its
* rules apply fleet-wide instead of just to the originating flow.
*
* TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — should create the
* policy from the pipeline's stages and return the new policy id. The mock
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
*/
export async function promoteToPolicy(id: string): Promise<{ ok: true }> {
return httpJson<{ ok: true }>(
`/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
{ method: "POST" },
);
}
+90
View File
@@ -0,0 +1,90 @@
import { httpJson } from "@portal/api/http";
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
/**
* Policies service layer — the backend contract.
*
* Unlike every other portal surface (which use the mock `/v1/...` base), this
* one calls the REAL Stirling policy API base `/api/v1/policies` so it is
* genuinely plug-and-play: drop MSW and these exact calls hit the live backend
* (PolicyController). The list response is the portal's catalogue shape; the
* single-policy / create / delete / run calls match the backend records.
*/
export type {
CatalogueEntry,
DecoratedPolicy,
InputSpec,
OutputSpec,
PipelineStep,
PoliciesResponse,
PoliciesSummary,
Policy,
PolicyActivityItem,
PolicyCategory,
PolicyConfigDef,
PolicyField,
PolicyFieldType,
PolicyRowStatus,
PolicySetupResult,
PolicySource,
PolicyState,
PolicyStats,
PolicyStatus,
TriggerConfig,
} from "@portal/mocks/policies";
export {
ENDPOINT_LABELS,
POLICY_CATEGORIES,
POLICY_CONFIG,
POLICY_DOC_TYPES,
POLICY_SOURCES,
TOOL_ENDPOINTS,
humanizeEndpoint,
} from "@portal/mocks/policies";
/** GET /api/v1/policies — the catalogue + every configured policy. */
export async function fetchPolicies(): Promise<PoliciesResponse> {
return httpJson<PoliciesResponse>("/api/v1/policies");
}
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
export async function fetchPolicy(id: string): Promise<Policy> {
return httpJson<Policy>(`/api/v1/policies/${encodeURIComponent(id)}`);
}
/**
* POST /api/v1/policies — create (blank id) or update (matched id). The backend
* assigns owner + team server-side and returns the stored policy with its id.
*/
export async function savePolicy(policy: Policy): Promise<Policy> {
return httpJson<Policy>("/api/v1/policies", { method: "POST", body: policy });
}
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
export async function deletePolicy(id: string): Promise<void> {
await httpJson<void>(`/api/v1/policies/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** The async run acknowledgement: a run id to poll for status. */
export interface PolicyRunResponse {
status: boolean;
/** The run id (poll GET /api/v1/policies/run/{id} for status). */
fileId: string | null;
message: string | null;
}
/**
* POST /api/v1/policies/{id}/run — run a stored policy now. The real endpoint
* is multipart (the documents to process); the portal has no files to attach,
* so this triggers the policy on whatever the backend has queued and returns a
* run id. Runs regardless of the policy's enabled flag.
*/
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
return httpJson<PolicyRunResponse>(
`/api/v1/policies/${encodeURIComponent(id)}/run`,
{ method: "POST" },
);
}
+28
View File
@@ -0,0 +1,28 @@
import { httpJson } 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";
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
return httpJson<ComponentsResponse>(
`/v1/components?tier=${encodeURIComponent(tier)}`,
);
}
+3
View File
@@ -3,8 +3,11 @@ 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";
+24
View File
@@ -0,0 +1,24 @@
import { httpJson } from "@portal/api/http";
import type { UsersResponse } from "@portal/mocks/users";
import type { Tier } from "@portal/contexts/TierContext";
export type {
AccessControls,
Member,
MemberStatus,
Role,
RoleId,
UsersResponse,
UsersSummary,
} from "@portal/mocks/users";
export {
MEMBER_STATUS_TONE,
ROLES,
ROLE_LABEL,
ROLE_TONE,
} from "@portal/mocks/users";
/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */
export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
return httpJson<UsersResponse>(`/v1/users?tier=${encodeURIComponent(tier)}`);
}
@@ -1,227 +0,0 @@
.portal-doctype {
display: flex;
flex-direction: column;
gap: 1rem;
}
.portal-doctype__head {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.portal-doctype__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-doctype__sub {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-4);
}
/* Tabs */
.portal-doctype__tabs {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.portal-doctype__tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3125rem 0.625rem;
font-size: 0.75rem;
color: var(--color-text-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
transition:
border-color var(--motion-fast),
background var(--motion-fast),
color var(--motion-fast);
}
.portal-doctype__tab:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-doctype__tab.is-active {
color: var(--portal-tab-accent, var(--color-blue));
border-color: var(--portal-tab-accent, var(--color-blue));
background: var(--color-surface);
font-weight: 500;
}
.portal-doctype__tab-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.portal-doctype__tab-count {
font-size: 0.6875rem;
color: var(--color-text-5);
}
/* Vertical groups */
.portal-doctype__groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.portal-doctype__group-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.625rem;
}
.portal-doctype__group-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-2);
}
.portal-doctype__group-count {
font-size: 0.75rem;
color: var(--color-text-5);
}
.portal-doctype__scroller {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scroll-snap-type: x mandatory;
scrollbar-width: thin;
}
.portal-doctype__scroller::-webkit-scrollbar {
height: 0.375rem;
}
.portal-doctype__scroller::-webkit-scrollbar-thumb {
background: var(--color-border-hover);
border-radius: var(--radius-pill);
}
/* Cards */
.portal-doctype__card {
flex: 0 0 17rem;
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
scroll-snap-align: start;
transition:
border-color var(--motion-fast),
box-shadow var(--motion-fast),
transform var(--motion-fast);
}
.portal-doctype__card:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-md);
transform: translateY(-0.0625rem);
}
.portal-doctype__card-accent {
width: 0.25rem;
flex-shrink: 0;
}
.portal-doctype__card-body {
flex: 1 1 auto;
padding: 0.875rem 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.portal-doctype__card-eyebrow {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--color-text-5);
}
.portal-doctype__card-title {
margin: 0.125rem 0 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-doctype__card-desc {
margin: 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-text-4);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.portal-doctype__card-meta {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-doctype__regions {
font-family: var(--font-mono);
}
.portal-doctype__regions-more {
color: var(--color-text-5);
}
.portal-doctype__card-cta {
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
transition: opacity var(--motion-fast);
}
.portal-doctype__card-cta:hover {
opacity: 0.8;
}
.portal-doctype__skel,
.portal-doctype__card-skel {
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
var(--color-bg-muted) 0%,
var(--color-bg-hover) 50%,
var(--color-bg-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.portal-doctype__skel {
display: inline-block;
height: 0.75rem;
}
.portal-doctype__card-skel {
flex: 0 0 17rem;
height: 8rem;
border: 1px solid var(--color-border-light);
}
@@ -1,41 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { DocumentTypeGrid } from "@portal/components/DocumentTypeGrid";
const meta: Meta<typeof DocumentTypeGrid> = {
title: "Portal/Home/DocumentTypeGrid",
component: DocumentTypeGrid,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentTypeGrid>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/endpoints", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [http.get("/v1/endpoints", () => HttpResponse.json([]))],
},
},
};
@@ -1,181 +0,0 @@
import { useState } from "react";
import {
EmptyState,
Skeleton,
StatusBadge,
Tabs,
type TabItem,
} from "@shared/components";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchVerticals,
type Endpoint,
type Vertical,
type VerticalKey,
} from "@portal/api/endpoints";
import "@portal/components/DocumentTypeGrid.css";
type ActiveTab = VerticalKey | "all";
const TIER_LABEL = ["Free", "Paid", "Enterprise"] as const;
const TIER_TONE = ["success", "info", "purple"] as const;
function tierBadge(tier: 0 | 1 | 2) {
return (
<StatusBadge tone={TIER_TONE[tier]} size="sm">
{TIER_LABEL[tier]}
</StatusBadge>
);
}
function EndpointCard({
endpoint,
vertical,
}: {
endpoint: Endpoint;
vertical: Vertical;
}) {
const visibleRegions = endpoint.regions.slice(0, 2);
const extraRegions = endpoint.regions.length - visibleRegions.length;
return (
<article className="portal-doctype__card">
<div
className="portal-doctype__card-accent"
style={{ background: vertical.color }}
aria-hidden
/>
<div className="portal-doctype__card-body">
<div className="portal-doctype__card-eyebrow">
{vertical.label.toUpperCase()}
</div>
<h3 className="portal-doctype__card-title">{endpoint.name}</h3>
<p className="portal-doctype__card-desc">{endpoint.desc}</p>
<div className="portal-doctype__card-meta">
<span className="portal-doctype__regions">
{visibleRegions.join(" · ")}
{extraRegions > 0 && (
<span className="portal-doctype__regions-more">
{" "}
+{extraRegions} more
</span>
)}
</span>
{tierBadge(endpoint.tier)}
</div>
<a
className="portal-doctype__card-cta"
style={{ color: vertical.color }}
href={`#${endpoint.endpoint}`}
onClick={(e) => e.preventDefault()}
>
Explore <span aria-hidden></span>
</a>
</div>
</article>
);
}
function GridSkeleton() {
return (
<div className="portal-doctype__groups" aria-hidden>
{Array.from({ length: 2 }).map((_, gi) => (
<div key={gi} className="portal-doctype__group">
<div className="portal-doctype__group-head">
<Skeleton shape="circle" width="0.4375rem" height="0.4375rem" />
<Skeleton width="6rem" />
</div>
<div className="portal-doctype__scroller">
{Array.from({ length: 4 }).map((_, ci) => (
<Skeleton key={ci} shape="rect" width="17rem" height="8rem" />
))}
</div>
</div>
))}
</div>
);
}
export function DocumentTypeGrid() {
const [tab, setTab] = useState<ActiveTab>("all");
const state = useAsync<Vertical[]>(() => fetchVerticals(), []);
const { data: verticals } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const hasVerticals = verticals !== null && verticals.length > 0;
const tabItems: TabItem<ActiveTab>[] = hasVerticals
? [
{ key: "all", label: "All" },
...verticals.map<TabItem<ActiveTab>>((v) => ({
key: v.key,
label: v.label,
count: v.endpoints.length,
accentColor: v.color,
dotColor: v.color,
})),
]
: [];
return (
<section className="portal-doctype" aria-label="Document types">
<header className="portal-doctype__head">
<h2 className="portal-doctype__title">Document types</h2>
<p className="portal-doctype__sub">
Typed endpoints across every supported vertical each carries a
schema, region availability and tier gate.
</p>
</header>
{hasVerticals && (
<Tabs<ActiveTab>
items={tabItems}
activeKey={tab}
onChange={setTab}
ariaLabel="Document type verticals"
/>
)}
{isLoading && <GridSkeleton />}
{isEmpty && (
<EmptyState
title="No document types yet"
description="When endpoints are registered they'll appear in this catalogue."
/>
)}
{hasVerticals && (
<div className="portal-doctype__groups">
{(tab === "all"
? verticals
: verticals.filter((v) => v.key === tab)
).map((v) => (
<div key={v.key} className="portal-doctype__group">
{tab === "all" && (
<div className="portal-doctype__group-head">
<span
className="portal-doctype__tab-dot"
style={{ background: v.color }}
aria-hidden
/>
<h3 className="portal-doctype__group-title">{v.label}</h3>
<span className="portal-doctype__group-count">
{v.endpoints.length} endpoints
</span>
</div>
)}
<div className="portal-doctype__scroller">
{v.endpoints.map((endpoint) => (
<EndpointCard
key={endpoint.endpoint}
endpoint={endpoint}
vertical={v}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,225 @@
.portal-fork__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.875rem;
}
.portal-fork__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-fork__sub {
margin: 0.125rem 0 0;
font-size: 0.75rem;
color: var(--color-text-4);
max-width: 32rem;
}
/* Template picker */
.portal-fork__templates {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
}
@media (max-width: 48rem) {
.portal-fork__templates {
grid-template-columns: 1fr;
}
}
.portal-fork__template {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem 0.875rem;
text-align: left;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-left: 3px solid var(--accent, var(--color-blue));
border-radius: var(--radius-md);
transition:
background var(--motion-fast),
border-color var(--motion-fast),
transform var(--motion-fast);
}
.portal-fork__template[data-accent="blue"] {
--accent: var(--color-blue);
}
.portal-fork__template[data-accent="purple"] {
--accent: var(--color-purple);
}
.portal-fork__template[data-accent="green"] {
--accent: var(--color-green);
}
.portal-fork__template[data-accent="amber"] {
--accent: var(--color-amber);
}
.portal-fork__template:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
border-left-color: var(--accent);
transform: translateY(-1px);
}
.portal-fork__template-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-fork__template-blurb {
font-size: 0.75rem;
color: var(--color-text-4);
line-height: 1.45;
}
.portal-fork__template-types {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.125rem;
}
/* Build / ready state */
.portal-fork__build-head {
display: flex;
flex-direction: column;
margin-bottom: 0.875rem;
}
.portal-fork__build-head strong {
font-size: 0.875rem;
color: var(--color-text-1);
}
.portal-fork__build-head span {
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-fork__stages {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
@media (max-width: 48rem) {
.portal-fork__stages {
grid-template-columns: 1fr 1fr;
}
}
.portal-fork__stage {
position: relative;
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
opacity: 0.55;
transition:
opacity var(--motion-base),
border-color var(--motion-base),
background var(--motion-base);
}
.portal-fork__stage.is-active,
.portal-fork__stage.is-done {
opacity: 1;
}
.portal-fork__stage.is-active {
border-color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-fork__stage.is-done {
border-color: color-mix(in srgb, var(--color-green) 35%, transparent);
background: var(--color-green-light);
}
.portal-fork__stage-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
color: var(--color-text-4);
}
.portal-fork__stage.is-done .portal-fork__stage-mark {
background: var(--color-green);
border-color: var(--color-green);
color: var(--color-text-on-accent);
}
.portal-fork__stage.is-active .portal-fork__stage-mark {
border-color: var(--color-blue);
color: var(--color-blue);
}
.portal-fork__stage-text {
display: flex;
flex-direction: column;
}
.portal-fork__stage-text strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-fork__stage-text span {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.4;
}
.portal-fork__stage-spin {
position: absolute;
top: 0.625rem;
right: 0.625rem;
width: 0.875rem;
height: 0.875rem;
border: 2px solid color-mix(in srgb, var(--color-blue) 30%, transparent);
border-top-color: var(--color-blue);
border-radius: 50%;
animation: portal-fork-spin 0.7s linear infinite;
}
@keyframes portal-fork-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.portal-fork__stage-spin {
animation: none;
}
}
.portal-fork__build-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
const meta: Meta<typeof PipelineForkWizard> = {
title: "Portal/Home/PipelineForkWizard",
component: PipelineForkWizard,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PipelineForkWizard>;
/** Pick a template to watch the deterministic four-stage build animation. */
export const Default: Story = {};
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { Button, Card, Chip, StatusBadge } from "@shared/components";
import { useView } from "@portal/contexts/ViewContext";
import {
PIPELINE_STAGES,
PIPELINE_TEMPLATES,
type PipelineTemplate,
} from "@portal/api/home";
import "@portal/components/PipelineForkWizard.css";
/**
* Wizard phases:
* - `pick` — choose a starter template
* - `building` — deterministic stage-by-stage build animation
* - `ready` — all four stages lit; offer deploy
*/
type Phase = "pick" | "building" | "ready";
/** Time each build stage stays "in progress" before the next lights up. */
const STAGE_STEP_MS = 550;
export function PipelineForkWizard() {
const { setActiveView } = useView();
const [phase, setPhase] = useState<Phase>("pick");
const [template, setTemplate] = useState<PipelineTemplate | null>(null);
// How many stages have completed. Drives both the animation and the
// pick→building→ready transitions; advanced purely by a fixed-interval timer
// so the sequence is identical on every run (no Math.random / Date.now).
const [builtStages, setBuiltStages] = useState(0);
const timerRef = useRef<number | null>(null);
// Advance one stage per tick while building; settle into `ready` once all
// stages are done. The effect re-arms itself on each builtStages change
// rather than holding a single long-lived interval, so cleanup is trivial.
useEffect(() => {
if (phase !== "building") return;
if (builtStages >= PIPELINE_STAGES.length) {
setPhase("ready");
return;
}
timerRef.current = window.setTimeout(() => {
setBuiltStages((n) => n + 1);
}, STAGE_STEP_MS);
return () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
};
}, [phase, builtStages]);
function fork(t: PipelineTemplate) {
setTemplate(t);
setBuiltStages(0);
setPhase("building");
}
function reset() {
setPhase("pick");
setTemplate(null);
setBuiltStages(0);
}
function deploy() {
// TODO(backend): POST /v1/pipelines { templateId, name } to create the
// forked pipeline. Without a backend, route to the pipelines list.
setActiveView("pipelines");
}
return (
<Card padding="loose" className="portal-fork">
<header className="portal-fork__head">
<div>
<h2 className="portal-fork__title">Fork a starter pipeline</h2>
<p className="portal-fork__sub">
Clone a proven workflow and tune it every template ships the same
four-stage backbone.
</p>
</div>
{phase !== "pick" && template && (
<StatusBadge tone={phase === "ready" ? "success" : "info"} size="sm">
{phase === "ready" ? "Ready to deploy" : "Building…"}
</StatusBadge>
)}
</header>
{phase === "pick" && (
<div className="portal-fork__templates">
{PIPELINE_TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
className="portal-fork__template"
data-accent={t.accent}
onClick={() => fork(t)}
>
<span className="portal-fork__template-name">{t.name}</span>
<span className="portal-fork__template-blurb">{t.blurb}</span>
<span className="portal-fork__template-types">
{t.docTypes.map((d) => (
<Chip key={d} size="sm" tone="neutral">
{d}
</Chip>
))}
</span>
</button>
))}
</div>
)}
{phase !== "pick" && template && (
<div className="portal-fork__build">
<div className="portal-fork__build-head">
<strong>{template.name}</strong>
<span>{template.blurb}</span>
</div>
<ol className="portal-fork__stages">
{PIPELINE_STAGES.map((stage, i) => {
const done = i < builtStages;
const active = phase === "building" && i === builtStages;
const cls =
"portal-fork__stage" +
(done ? " is-done" : "") +
(active ? " is-active" : "");
return (
<li key={stage.key} className={cls}>
<span className="portal-fork__stage-mark" aria-hidden>
{done ? "✓" : i + 1}
</span>
<span className="portal-fork__stage-text">
<strong>{stage.label}</strong>
<span>{stage.detail}</span>
</span>
{active && (
<span className="portal-fork__stage-spin" aria-hidden />
)}
</li>
);
})}
</ol>
<div className="portal-fork__build-actions">
<Button variant="ghost" size="sm" onClick={reset}>
{phase === "ready" ? "Pick another" : "Cancel"}
</Button>
<Button
variant="gradient"
size="sm"
onClick={deploy}
disabled={phase !== "ready"}
trailingIcon={<span aria-hidden></span>}
>
{phase === "ready" ? "Deploy pipeline" : "Building…"}
</Button>
</div>
</div>
)}
</Card>
);
}
@@ -0,0 +1,76 @@
.portal-policysum__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.125rem 0.875rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-policysum__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-policysum__sub {
margin: 0.125rem 0 0;
font-size: 0.75rem;
color: var(--color-text-4);
max-width: 34rem;
}
.portal-policysum__cat {
display: flex;
align-items: center;
gap: 0.625rem;
}
.portal-policysum__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
flex: none;
border-radius: var(--radius-md);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
font-size: 0.9375rem;
}
.portal-policysum__cat-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-policysum__cat-text strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-policysum__cat-text span {
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-policysum__rule {
font-size: 0.75rem;
color: var(--color-text-3);
}
.portal-policysum__loading {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.125rem;
}
.portal-policysum__loading-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { PolicySummary } from "@portal/components/PolicySummary";
const meta: Meta<typeof PolicySummary> = {
title: "Portal/Home/PolicySummary",
component: PolicySummary,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "60rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PolicySummary>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", async () => {
await delay("infinite");
return HttpResponse.json({});
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", () =>
HttpResponse.json({
summary: { active: 0, paused: 0, categories: 0, docsEnforced: 0 },
catalogue: [],
}),
),
],
},
},
};
@@ -0,0 +1,172 @@
import {
Button,
Card,
EmptyState,
Skeleton,
StatusBadge,
Table,
type TableColumn,
} from "@shared/components";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchPolicies,
type CatalogueEntry,
type PoliciesResponse,
} from "@portal/api/policies";
import { policyIcon } from "@portal/components/policies/policyIcons";
import "@portal/components/PolicySummary.css";
/**
* Each row's display state collapses a category's facts into one of three
* mutually-exclusive shapes:
*
* - `locked` — a coming-soon category; show a "Soon" affordance
* - `active` — a configured policy that's enabled; offer "Configure"
* - `off` — available but not set up (or paused); offer "Set up"
*/
type RowState = "locked" | "active" | "off";
interface PolicyRow {
entry: CatalogueEntry;
state: RowState;
}
const STATE_BADGE: Record<
RowState,
{ tone: "success" | "neutral" | "info"; label: string }
> = {
active: { tone: "success", label: "Active" },
off: { tone: "neutral", label: "Off" },
locked: { tone: "info", label: "Soon" },
};
function toRow(entry: CatalogueEntry): PolicyRow {
if (entry.category.comingSoon) return { entry, state: "locked" };
const active = entry.policy?.state.status === "active";
return { entry, state: active ? "active" : "off" };
}
export function PolicySummary() {
const { setActiveView } = useView();
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), []);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const goToPolicies = () => setActiveView("policies");
const columns: TableColumn<PolicyRow>[] = [
{
key: "category",
header: "Policy",
render: ({ entry }) => (
<div className="portal-policysum__cat">
<span className="portal-policysum__icon" aria-hidden>
{policyIcon(entry.category.icon)}
</span>
<div className="portal-policysum__cat-text">
<strong>{entry.category.label}</strong>
<span>{entry.category.desc}</span>
</div>
</div>
),
},
{
key: "status",
header: "Status",
width: "7rem",
render: ({ state }) => {
const badge = STATE_BADGE[state];
return (
<StatusBadge tone={badge.tone} size="sm">
{badge.label}
</StatusBadge>
);
},
},
{
key: "rule",
header: "Active rule",
render: ({ entry, state }) => (
<span className="portal-policysum__rule">
{state === "active" ? entry.config.summary : "No rule enforced yet"}
</span>
),
},
{
key: "action",
header: "",
align: "right",
width: "9rem",
render: ({ state }) => {
if (state === "locked") {
return (
<Button size="sm" variant="ghost" onClick={goToPolicies}>
Coming soon
</Button>
);
}
return (
<Button
size="sm"
variant={state === "active" ? "ghost" : "outline"}
onClick={goToPolicies}
>
{state === "active" ? "Configure" : "Set up"}
</Button>
);
},
},
];
const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? [];
return (
<section className="portal-policysum" aria-label="What runs on your PDFs">
<Card padding="none">
<header className="portal-policysum__head">
<div>
<h2 className="portal-policysum__title">What runs on your PDFs</h2>
<p className="portal-policysum__sub">
Standing automations every document passes through, regardless of
which pipeline handles it.
</p>
</div>
{data && (
<StatusBadge tone="info" size="sm">
{data.summary.active} / {data.summary.categories} active
</StatusBadge>
)}
</header>
{isLoading && (
<div className="portal-policysum__loading" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="portal-policysum__loading-row">
<Skeleton width="9rem" />
<Skeleton width="60%" height="0.625rem" />
</div>
))}
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title="No policies yet"
description="Once policies are configured, the categories appear here."
/>
)}
{data && rows.length > 0 && (
<Table
columns={columns}
rows={rows}
rowKey={(r) => r.entry.category.id}
onRowClick={goToPolicies}
/>
)}
</Card>
</section>
);
}
@@ -0,0 +1,78 @@
.portal-statusstrip--free {
display: flex;
flex-direction: column;
}
/* The Banner body already provides padding; lay out the free meter inside it. */
.portal-statusstrip--free .sui-banner__body {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.portal-statusstrip__free-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.portal-statusstrip__free-label {
font-size: 0.8125rem;
color: var(--color-text-2);
}
.portal-statusstrip__free-label strong {
color: var(--color-text-1);
font-weight: 600;
}
.portal-statusstrip__free-pct {
font-size: 0.75rem;
font-family: var(--font-mono);
color: var(--color-text-4);
}
/* Paid strip: single inline row */
.portal-statusstrip--paid {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.portal-statusstrip__plan {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-statusstrip__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-statusstrip__sep {
color: var(--color-text-5);
}
.portal-statusstrip__volume {
font-size: 0.8125rem;
color: var(--color-text-3);
}
.portal-statusstrip__volume strong {
color: var(--color-text-1);
font-weight: 600;
}
.portal-statusstrip__manage {
margin-left: auto;
}
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
const meta: Meta<typeof ProcessingStatusStrip> = {
title: "Portal/Home/ProcessingStatusStrip",
component: ProcessingStatusStrip,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "60rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ProcessingStatusStrip>;
/** Switch the Tier toolbar to compare the free meter vs the paid plan row. */
export const Default: Story = {};
/** Free tier pushed near its cap so the meter and upgrade nudge turn amber. */
export const FreeNearCap: Story = {
globals: { tier: "free" },
parameters: {
msw: {
handlers: [
http.get("/v1/home/kpis", async () => {
await delay(80);
return HttpResponse.json([{ value: "472 / 500" }]);
}),
],
},
},
};
@@ -0,0 +1,112 @@
import { Banner, Button, ProgressBar, Skeleton } from "@shared/components";
import { TIER_INFO, useTier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
import "@portal/components/ProcessingStatusStrip.css";
/**
* Parses the free-tier "used / cap" KPI string (e.g. "247 / 500") into its
* parts. The free meter is the headline KPI value rather than a separate
* endpoint, so the strip reads it from the same `fetchHomeKpis` payload the
* KPI cards use — no duplicate fetch, no second source of truth.
*/
function parseUsage(value: KpiEntry["value"]): {
used: number;
cap: number;
} | null {
const match = String(value).match(/([\d,]+)\s*\/\s*([\d,]+)/);
if (!match) return null;
const used = Number(match[1].replace(/,/g, ""));
const cap = Number(match[2].replace(/,/g, ""));
if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0) return null;
return { used, cap };
}
export function ProcessingStatusStrip() {
const { tier } = useTier();
const { setActiveView } = useView();
const { data: kpis, loading } = useAsync<KpiEntry[]>(
() => fetchHomeKpis(tier),
[tier],
);
if (loading) {
return (
<div className="portal-statusstrip" aria-busy>
<Skeleton width="9rem" height="0.875rem" />
<Skeleton height="0.5rem" />
</div>
);
}
if (tier === "free") {
const usage = parseUsage(kpis?.[0]?.value ?? "");
const used = usage?.used ?? 0;
const cap = usage?.cap ?? 500;
const ratio = cap > 0 ? used / cap : 0;
const nearCap = ratio >= 0.8;
return (
<Banner
tone={nearCap ? "warning" : "neutral"}
className="portal-statusstrip portal-statusstrip--free"
action={
nearCap ? (
<Button
size="sm"
variant="outline"
onClick={() => setActiveView("usage")}
>
Upgrade
</Button>
) : undefined
}
>
<div className="portal-statusstrip__free-row">
<span className="portal-statusstrip__free-label">
<strong>{used.toLocaleString()}</strong> / {cap.toLocaleString()}{" "}
PDFs this month
</span>
<span className="portal-statusstrip__free-pct">
{Math.round(ratio * 100)}%
</span>
</div>
<ProgressBar
value={ratio}
thresholded
label={`${used} of ${cap} PDFs used this month`}
/>
</Banner>
);
}
// Pro / enterprise: plan name + headline volume from the first KPI.
const volume = kpis?.[0]?.value;
return (
<div className="portal-statusstrip portal-statusstrip--paid">
<span className="portal-statusstrip__plan">
<span
className="portal-statusstrip__dot"
style={{ background: TIER_INFO[tier].dotColor }}
aria-hidden
/>
{TIER_INFO[tier].label}
</span>
<span className="portal-statusstrip__sep" aria-hidden>
·
</span>
<span className="portal-statusstrip__volume">
<strong>{volume ?? "—"}</strong> PDFs processed · last 30 days
</span>
<Button
size="sm"
variant="ghost"
className="portal-statusstrip__manage"
onClick={() => setActiveView("usage")}
>
Manage plan
</Button>
</div>
);
}
@@ -1,17 +1,8 @@
/* The modal body scrolls; keep the tab strip pinned at the top of it. */
.portal-settings__tabs {
position: sticky;
top: -1rem;
z-index: 1;
margin: -1rem -1.125rem 0;
padding: 0.5rem 1.125rem 0;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border-light);
}
.portal-settings__panel {
padding-top: 1rem;
min-height: 18rem;
/* The settings overlay hosts a full-bleed two-pane SettingsShell, so the
modal frame contributes no padding of its own and lets the shell scroll. */
.portal-settings .sui-modal__body {
padding: 0;
overflow: hidden;
}
.portal-settings__section {
@@ -221,6 +212,13 @@
color: var(--color-text-4);
}
/* Label paired with a gating badge (e.g. "SCIM provisioning" + Enterprise). */
.portal-settings__row-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
/* ── Workspace plan card ──────────────────────────────────────────────── */
.portal-settings__plan {
display: flex;
+426 -62
View File
@@ -6,20 +6,49 @@ import {
Input,
Modal,
Select,
SettingsShell,
Skeleton,
StatusBadge,
Tabs,
ToggleSwitch,
type SelectOption,
type TabItem,
type SettingsNavSection,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useTier, type Tier } from "@portal/contexts/TierContext";
import { useTheme, type Theme } from "@portal/contexts/ThemeContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchSettings, type SettingsSnapshot } from "@portal/api/settings";
import {
fetchSettings,
type ActiveSession,
type BetaFeature,
type SettingsSnapshot,
} from "@portal/api/settings";
import {
UsersIcon,
SunIcon,
BellIcon,
SettingsIcon,
PoliciesIcon,
InfrastructureIcon,
SparklesIcon,
} from "@portal/components/icons";
import "@portal/components/SettingsModal.css";
type SettingsTab = "profile" | "preferences" | "workspace";
type SettingsSection =
| "profile"
| "appearance"
| "notifications"
| "general"
| "authentication"
| "sessions"
| "early-access";
/** Org-wide auth posture the Admin sections edit, mirrored into local state. */
interface SecurityForm {
mfaEnforced: boolean;
ssoEnabled: boolean;
scimEnabled: boolean;
sessionTimeoutMins: number;
}
interface SettingsModalProps {
open: boolean;
@@ -57,10 +86,55 @@ const NOTIFICATION_COPY: Record<
},
};
const TABS: TabItem<SettingsTab>[] = [
{ key: "profile", label: "Profile" },
{ key: "preferences", label: "Preferences" },
{ key: "workspace", label: "Workspace" },
const SECTION_LABEL: Record<SettingsSection, string> = {
profile: "Profile",
appearance: "Appearance",
notifications: "Notifications",
general: "General",
authentication: "Authentication",
sessions: "Active sessions",
"early-access": "Early access",
};
const NAV_SECTIONS: SettingsNavSection[] = [
{
title: "Account",
items: [
{ key: "profile", label: "Profile", icon: <UsersIcon size={16} /> },
{ key: "appearance", label: "Appearance", icon: <SunIcon size={16} /> },
{
key: "notifications",
label: "Notifications",
icon: <BellIcon size={16} />,
},
],
},
{
title: "Workspace",
items: [
{ key: "general", label: "General", icon: <SettingsIcon size={16} /> },
],
},
{
title: "Admin",
items: [
{
key: "authentication",
label: "Authentication",
icon: <PoliciesIcon size={16} />,
},
{
key: "sessions",
label: "Active sessions",
icon: <InfrastructureIcon size={16} />,
},
{
key: "early-access",
label: "Early access",
icon: <SparklesIcon size={16} />,
},
],
},
];
const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [
@@ -68,17 +142,24 @@ const THEME_OPTIONS: { value: Theme; label: string; hint: string }[] = [
{ value: "dark", label: "Dark", hint: "Dim surfaces" },
];
const SESSION_TIMEOUT_OPTIONS: SelectOption[] = [
{ value: "60", label: "1 hour" },
{ value: "240", label: "4 hours" },
{ value: "480", label: "8 hours" },
{ value: "720", label: "12 hours" },
{ value: "1440", label: "24 hours" },
];
/**
* Account settings as a portal-wide overlay. Opens onto a tier-aware snapshot
* (profile, notification defaults, workspace + region) which seeds editable
* local form state. Save is a no-op for the demo — it simply closes — but the
* theme control writes straight through to ThemeProvider so the change is real
* and visible immediately.
* Account settings as a portal-wide overlay. A grouped left-nav (Account /
* Workspace / Admin) over a tier-aware snapshot that seeds editable local form
* state. Save is a no-op for the demo — it closes — but the theme control
* writes straight through to ThemeProvider so the change is real and visible.
*/
export function SettingsModal({ open, onClose }: SettingsModalProps) {
const { tier } = useTier();
const { theme, setTheme } = useTheme();
const [tab, setTab] = useState<SettingsTab>("profile");
const [section, setSection] = useState<SettingsSection>("profile");
const { data: snapshot, loading } = useAsync<SettingsSnapshot>(
() => fetchSettings(tier),
@@ -95,6 +176,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [notifications, setNotifications] = useState<Record<string, boolean>>(
{},
);
const [security, setSecurity] = useState<SecurityForm>({
mfaEnforced: false,
ssoEnabled: false,
scimEnabled: false,
sessionTimeoutMins: 480,
});
const [betaToggles, setBetaToggles] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!snapshot) return;
@@ -105,10 +193,19 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
setNotifications(
Object.fromEntries(snapshot.notifications.map((n) => [n.id, n.enabled])),
);
setSecurity({
mfaEnforced: snapshot.security.mfaEnforced,
ssoEnabled: snapshot.security.ssoEnabled,
scimEnabled: snapshot.security.scimEnabled,
sessionTimeoutMins: snapshot.security.sessionTimeoutMins,
});
setBetaToggles(
Object.fromEntries(snapshot.betaFeatures.map((f) => [f.id, f.enabled])),
);
}, [snapshot]);
useEffect(() => {
if (open) setTab("profile");
if (open) setSection("profile");
}, [open]);
const regionOptions = useMemo<SelectOption[]>(() => {
@@ -129,36 +226,31 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
<Modal
open={open}
onClose={onClose}
width="lg"
title="Settings"
subtitle="Manage your profile, preferences, and workspace."
width="xl"
ariaLabel="Settings"
className="portal-settings"
footer={
<>
<span className="portal-settings__footer-note">
Changes apply to this workspace.
</span>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="gradient" onClick={onClose}>
Save changes
</Button>
</>
}
>
<div className="portal-settings__tabs">
<Tabs
items={TABS}
activeKey={tab}
onChange={setTab}
variant="underline"
ariaLabel="Settings sections"
/>
</div>
<div className="portal-settings__panel">
{tab === "profile" && (
<SettingsShell
sections={NAV_SECTIONS}
activeKey={section}
onSelect={(k) => setSection(k as SettingsSection)}
title={SECTION_LABEL[section]}
onClose={onClose}
footer={
<>
<span className="portal-settings__footer-note">
Changes apply to this workspace.
</span>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="gradient" onClick={onClose}>
Save changes
</Button>
</>
}
>
{section === "profile" && (
<ProfilePanel
loading={isLoading}
name={name}
@@ -170,20 +262,22 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
/>
)}
{tab === "preferences" && (
<PreferencesPanel
{section === "appearance" && (
<AppearancePanel theme={theme} onTheme={setTheme} />
)}
{section === "notifications" && (
<NotificationsPanel
loading={isLoading}
notifications={notifications}
order={snapshot?.notifications.map((n) => n.id) ?? []}
onToggle={(id, value) =>
setNotifications((prev) => ({ ...prev, [id]: value }))
}
theme={theme}
onTheme={setTheme}
/>
)}
{tab === "workspace" && (
{section === "general" && (
<WorkspacePanel
loading={isLoading}
workspaceName={workspaceName}
@@ -195,7 +289,35 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
seats={snapshot?.workspace.seats}
/>
)}
</div>
{section === "authentication" && (
<AuthenticationPanel
loading={isLoading}
tier={tier}
security={security}
onSecurity={(patch) => setSecurity((s) => ({ ...s, ...patch }))}
/>
)}
{section === "sessions" && (
<SessionsPanel
loading={isLoading}
sessions={snapshot?.security.activeSessions ?? []}
/>
)}
{section === "early-access" && (
<EarlyAccessPanel
loading={isLoading}
tier={tier}
betaFeatures={snapshot?.betaFeatures ?? []}
betaToggles={betaToggles}
onBeta={(id, value) =>
setBetaToggles((prev) => ({ ...prev, [id]: value }))
}
/>
)}
</SettingsShell>
</Modal>
);
}
@@ -286,21 +408,13 @@ function ProfilePanel({
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Preferences */
/* Appearance */
/* ──────────────────────────────────────────────────────────────────────── */
function PreferencesPanel({
loading,
notifications,
order,
onToggle,
function AppearancePanel({
theme,
onTheme,
}: {
loading: boolean;
notifications: Record<string, boolean>;
order: string[];
onToggle: (id: string, value: boolean) => void;
theme: Theme;
onTheme: (theme: Theme) => void;
}) {
@@ -308,7 +422,7 @@ function PreferencesPanel({
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Appearance</h3>
<h3 className="portal-settings__group-title">Theme</h3>
<p className="portal-settings__group-sub">
Choose how the portal looks on this device.
</p>
@@ -345,10 +459,30 @@ function PreferencesPanel({
))}
</div>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Notifications */
/* ──────────────────────────────────────────────────────────────────────── */
function NotificationsPanel({
loading,
notifications,
order,
onToggle,
}: {
loading: boolean;
notifications: Record<string, boolean>;
order: string[];
onToggle: (id: string, value: boolean) => void;
}) {
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Notifications</h3>
<h3 className="portal-settings__group-title">Email notifications</h3>
<p className="portal-settings__group-sub">
Pick which events reach your inbox.
</p>
@@ -469,3 +603,233 @@ function WorkspacePanel({
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Admin · Authentication */
/* ──────────────────────────────────────────────────────────────────────── */
function AuthenticationPanel({
loading,
tier,
security,
onSecurity,
}: {
loading: boolean;
tier: Tier;
security: SecurityForm;
onSecurity: (patch: Partial<SecurityForm>) => void;
}) {
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
// SSO/SCIM are enterprise capabilities; below it they render locked with a
// badge rather than disappearing, so the upgrade path stays visible.
const isEnterprise = tier === "enterprise";
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Sign-in policy</h3>
<p className="portal-settings__group-sub">
Organisation-wide authentication controls.
</p>
</div>
<div className="portal-settings__notifs">
<div className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<strong>Enforce two-factor (MFA)</strong>
<span>Require every member to complete MFA at sign-in.</span>
</div>
<ToggleSwitch
checked={security.mfaEnforced}
onChange={(v) => onSecurity({ mfaEnforced: v })}
/>
</div>
<div className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<span className="portal-settings__row-label">
<strong>Single sign-on (SAML)</strong>
{!isEnterprise && (
<StatusBadge tone="info" size="sm" showDot={false}>
Enterprise
</StatusBadge>
)}
</span>
<span>Federate sign-in through your identity provider.</span>
</div>
<ToggleSwitch
checked={isEnterprise && security.ssoEnabled}
disabled={!isEnterprise}
onChange={(v) => onSecurity({ ssoEnabled: v })}
/>
</div>
<div className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<span className="portal-settings__row-label">
<strong>SCIM provisioning</strong>
{!isEnterprise && (
<StatusBadge tone="info" size="sm" showDot={false}>
Enterprise
</StatusBadge>
)}
</span>
<span>Sync members and roles from your directory.</span>
</div>
<ToggleSwitch
checked={isEnterprise && security.scimEnabled}
disabled={!isEnterprise}
onChange={(v) => onSecurity({ scimEnabled: v })}
/>
</div>
</div>
<FormField
label="Session timeout"
helperText="Members re-authenticate after this idle period."
>
<Select
value={String(security.sessionTimeoutMins)}
onChange={(e) =>
onSecurity({ sessionTimeoutMins: Number(e.target.value) })
}
options={SESSION_TIMEOUT_OPTIONS}
/>
</FormField>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Admin · Active sessions */
/* ──────────────────────────────────────────────────────────────────────── */
function SessionsPanel({
loading,
sessions,
}: {
loading: boolean;
sessions: ActiveSession[];
}) {
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Active sessions</h3>
<p className="portal-settings__group-sub">
Devices currently signed in to this account.
</p>
</div>
<div className="portal-settings__notifs">
{sessions.map((s) => (
<div key={s.id} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<strong>{s.device}</strong>
<span>
{s.location} · {s.lastActive}
</span>
</div>
{s.current ? (
<StatusBadge tone="success" size="sm">
This device
</StatusBadge>
) : (
// TODO(backend): DELETE /v1/settings/sessions/{id}
<Button variant="ghost" size="sm">
Revoke
</Button>
)}
</div>
))}
</div>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Admin · Early access */
/* ──────────────────────────────────────────────────────────────────────── */
function EarlyAccessPanel({
loading,
tier,
betaFeatures,
betaToggles,
onBeta,
}: {
loading: boolean;
tier: Tier;
betaFeatures: BetaFeature[];
betaToggles: Record<string, boolean>;
onBeta: (id: string, value: boolean) => void;
}) {
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
const isEnterprise = tier === "enterprise";
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">Preview features</h3>
<p className="portal-settings__group-sub">
Opt into features still in preview.
</p>
</div>
<div className="portal-settings__notifs">
{betaFeatures.map((f) => {
const locked = Boolean(f.enterpriseOnly) && !isEnterprise;
return (
<div key={f.id} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<span className="portal-settings__row-label">
<strong>{f.label}</strong>
{locked && (
<StatusBadge tone="info" size="sm" showDot={false}>
Enterprise
</StatusBadge>
)}
</span>
<span>{f.description}</span>
</div>
<ToggleSwitch
checked={!locked && (betaToggles[f.id] ?? false)}
disabled={locked}
onChange={(v) => onBeta(f.id, v)}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
+7 -7
View File
@@ -20,24 +20,24 @@
border-bottom: 1px solid var(--color-sidebar-divider);
}
/* Editor "Stirling PDF" wordmark logo (theme-switched in Sidebar.tsx) */
/* Stirling brand mark (theme-switched in Sidebar.tsx) + product wordmark */
.portal-sidebar__brand {
display: inline-flex;
align-items: center;
gap: 0.25rem;
gap: 0.4rem;
}
.portal-sidebar__brand-logo {
height: 1.5rem;
.portal-sidebar__brand-mark {
height: 1.375rem;
width: auto;
display: block;
}
/* TEMP: "portal" suffix styled to read as part of the wordmark */
.portal-sidebar__logo-suffix {
font-family: var(--font-brand);
font-size: 1.125rem;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-text-3);
color: var(--color-text-1);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* App switcher (down-arrow → Portal / Editor) */
+16 -11
View File
@@ -5,15 +5,16 @@ import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
import wordmarkLight from "@shared/assets/stirling-pdf-logo-light.svg";
import wordmarkDark from "@shared/assets/stirling-pdf-logo-dark.svg";
import markLight from "@shared/assets/stirling-mark-light.svg";
import markDark from "@shared/assets/stirling-mark-dark.svg";
import {
HomeIcon,
UsersIcon,
SourcesIcon,
PoliciesIcon,
PipelinesIcon,
DocumentsIcon,
ComponentsIcon,
InfrastructureIcon,
UsageIcon,
DocsIcon,
@@ -22,10 +23,9 @@ import {
} from "@portal/components/icons";
import "@portal/components/Sidebar.css";
// TEMP app switcher. The editor is a separate Vite app no shared shell yet
// (see PORTAL_INTEGRATION_PLAN.md), so switching is a hard navigation: the
// editor's own dev server in dev, the site root in prod. A standalone portal
// deploy will later gate this behind a configured editor URL.
// The editor is a separate Vite app with no shared shell, so switching apps is
// a hard navigation the editor's dev server in dev, the site root in prod.
// A standalone portal deploy can gate this behind a configured editor URL.
const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
interface NavEntry {
@@ -39,9 +39,12 @@ const GROUP_PRIMARY: NavEntry[] = [
];
const GROUP_OPERATIONAL: NavEntry[] = [
{ id: "users", label: "Users", icon: <UsersIcon /> },
{ id: "sources", label: "Sources", icon: <SourcesIcon /> },
{ id: "policies", label: "Policies", icon: <PoliciesIcon /> },
{ id: "pipelines", label: "Pipelines", icon: <PipelinesIcon /> },
{ id: "documents", label: "Documents", icon: <DocumentsIcon /> },
{ id: "components", label: "Components", icon: <ComponentsIcon /> },
];
const GROUP_PLATFORM: NavEntry[] = [
@@ -132,11 +135,13 @@ export function Sidebar() {
<div className="portal-sidebar__logo">
<span className="portal-sidebar__brand">
<img
className="portal-sidebar__brand-logo"
src={theme === "dark" ? wordmarkDark : wordmarkLight}
alt="Stirling PDF"
className="portal-sidebar__brand-mark"
src={theme === "dark" ? markDark : markLight}
alt="Stirling"
/>
<span className="portal-sidebar__logo-suffix">portal</span>
<span className="portal-sidebar__logo-suffix">
Stirling Processor
</span>
</span>
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
@@ -160,7 +165,7 @@ export function Sidebar() {
/>
}
>
Portal
Processor
</Dropdown.Item>
<Dropdown.Item
onSelect={() => {
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { AgentBuilderPanel } from "@portal/components/agent-builder/AgentBuilderPanel";
const PRO = agentsFor("pro");
const meta: Meta<typeof AgentBuilderPanel> = {
title: "Portal/AgentBuilder/AgentBuilderPanel",
component: AgentBuilderPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "52rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AgentBuilderPanel>;
/** Published agent with enterprise governance unlocked. */
export const Published: Story = {
args: { agent: PRO[0], governanceUnlocked: true },
};
/** Draft agent with governance locked (pro/free posture). */
export const DraftLocked: Story = {
args: { agent: PRO[2], governanceUnlocked: false },
};
@@ -0,0 +1,72 @@
import { useState } from "react";
import { StatusBadge, Tabs, type TabItem } from "@shared/components";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
import "@portal/views/AgentBuilder.css";
type BuilderTab = "scenarios" | "tools" | "evals" | "versions";
interface AgentBuilderPanelProps {
agent: Agent;
/** Enterprise unlocks restricted-tools governance and deep version history. */
governanceUnlocked: boolean;
}
/** The selected agent's builder: header + tabbed Scenarios / Tools / Evals / Versions. */
export function AgentBuilderPanel({
agent,
governanceUnlocked,
}: AgentBuilderPanelProps) {
const [tab, setTab] = useState<BuilderTab>("scenarios");
const tabs: TabItem<BuilderTab>[] = [
{ key: "scenarios", label: "Scenarios", count: agent.scenarios.length },
{ key: "tools", label: "Tools" },
{
key: "evals",
label: "Evals",
count: agent.evalsTotal > 0 ? agent.evalsTotal : undefined,
},
{ key: "versions", label: "Versions", count: agent.versions.length },
];
return (
<section className="portal-agents__builder">
<header className="portal-agents__builder-head">
<div>
<h2 className="portal-agents__builder-title">{agent.name}</h2>
<span className="portal-agents__builder-sub">{agent.role}</span>
</div>
<div className="portal-agents__builder-meta">
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
{agent.status}
</StatusBadge>
<code className="portal-agents__builder-version">
{agent.version}
</code>
<code className="portal-agents__builder-model">{agent.model}</code>
</div>
</header>
<Tabs<BuilderTab>
items={tabs}
activeKey={tab}
onChange={setTab}
variant="underline"
ariaLabel="Agent builder sections"
/>
{tab === "scenarios" && <ScenariosPanel agent={agent} />}
{tab === "tools" && (
<ToolsPanel agent={agent} governanceUnlocked={governanceUnlocked} />
)}
{tab === "evals" && <EvalsPanel agent={agent} />}
{tab === "versions" && (
<VersionsPanel agent={agent} historyUnlocked={governanceUnlocked} />
)}
</section>
);
}
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildAgentsResponse } from "@portal/mocks/agents";
import { AgentKpiStrip } from "@portal/components/agent-builder/AgentKpiStrip";
const meta: Meta<typeof AgentKpiStrip> = {
title: "Portal/AgentBuilder/AgentKpiStrip",
component: AgentKpiStrip,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AgentKpiStrip>;
export const Pro: Story = {
args: { summary: buildAgentsResponse("pro").summary, loading: false },
};
export const Enterprise: Story = {
args: { summary: buildAgentsResponse("enterprise").summary, loading: false },
};
export const Loading: Story = {
args: { summary: null, loading: true },
};
@@ -0,0 +1,52 @@
import { MetricCard, MetricStrip } from "@shared/components";
import type { AgentsSummary } from "@portal/api/agents";
/**
* KPI labels are product copy — they describe what each metric IS, not its
* value — so the strip's structure stays stable across loading / ready states;
* only values flow from the API.
*/
const KPI_LABELS = [
"Active agents",
"Avg eval pass rate",
"Scenarios",
"Latest published",
] as const;
interface AgentKpiStripProps {
summary: AgentsSummary | null;
loading: boolean;
}
export function AgentKpiStrip({ summary, loading }: AgentKpiStripProps) {
const values: (string | number)[] = summary
? [
summary.activeAgents,
`${Math.round(summary.avgPassRate * 100)}%`,
summary.totalScenarios,
summary.latestPublished,
]
: ["—", "—", "—", "—"];
const descriptions: (string | undefined)[] = summary
? [
`${summary.totalAgents} total`,
"across golden sets",
"test cases",
"fleet-wide",
]
: [];
return (
<MetricStrip>
{KPI_LABELS.map((label, i) => (
<MetricCard
key={label}
label={label}
value={loading ? "—" : values[i]}
description={loading ? undefined : descriptions[i]}
/>
))}
</MetricStrip>
);
}
@@ -0,0 +1,33 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { AgentSelector } from "@portal/components/agent-builder/AgentSelector";
const AGENTS = agentsFor("enterprise");
const meta: Meta<typeof AgentSelector> = {
title: "Portal/AgentBuilder/AgentSelector",
component: AgentSelector,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "18rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AgentSelector>;
export const Default: Story = {
args: { agents: AGENTS, selectedId: AGENTS[0].id, onSelect: () => {} },
};
/** Clicking a row moves the selection — drives which builder is shown. */
export const Interactive: Story = {
render: () => {
const [id, setId] = useState(AGENTS[0].id);
return <AgentSelector agents={AGENTS} selectedId={id} onSelect={setId} />;
},
};
@@ -0,0 +1,44 @@
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import { StatusBadge } from "@shared/components";
import "@portal/views/AgentBuilder.css";
interface AgentSelectorProps {
agents: Agent[];
selectedId: string | null;
onSelect: (id: string) => void;
}
/** Left-rail list of agents; the selected row drives which builder is shown. */
export function AgentSelector({
agents,
selectedId,
onSelect,
}: AgentSelectorProps) {
return (
<nav className="portal-agents__selector" aria-label="Agents">
{agents.map((a) => (
<button
key={a.id}
type="button"
className={
"portal-agents__selector-item" +
(a.id === selectedId ? " is-selected" : "")
}
aria-current={a.id === selectedId}
onClick={() => onSelect(a.id)}
>
<span className="portal-agents__selector-main">
<strong className="portal-agents__selector-name">{a.name}</strong>
<span className="portal-agents__selector-role">{a.role}</span>
</span>
<span className="portal-agents__selector-meta">
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
{a.status}
</StatusBadge>
<code className="portal-agents__selector-version">{a.version}</code>
</span>
</button>
))}
</nav>
);
}
@@ -0,0 +1,30 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@shared/components";
import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialog";
const meta: Meta<typeof BootstrapDialog> = {
title: "Portal/AgentBuilder/BootstrapDialog",
component: BootstrapDialog,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof BootstrapDialog>;
/** Open by default so the dialog is visible in the canvas. */
export const Open: Story = {
args: { open: true, onClose: () => {} },
};
/** Toggled from a trigger, mirroring how the view drives it. */
export const Triggered: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<div style={{ padding: "1.5rem" }}>
<Button onClick={() => setOpen(true)}>Bootstrap from document</Button>
<BootstrapDialog open={open} onClose={() => setOpen(false)} />
</div>
);
},
};
@@ -0,0 +1,70 @@
import { useState } from "react";
import { Button, Modal } from "@shared/components";
import "@portal/views/AgentBuilder.css";
interface BootstrapDialogProps {
open: boolean;
onClose: () => void;
}
/**
* Seed a new agent from a sample document — the user drops one representative
* file and the backend proposes scenarios and an extraction schema. Demo stub:
* it captures the chosen file name locally and closes without provisioning.
*/
export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
const [fileName, setFileName] = useState<string | null>(null);
function close() {
onClose();
setTimeout(() => setFileName(null), 200);
}
function bootstrap() {
// TODO(backend): POST /v1/agents/bootstrap (multipart sample document) —
// infer a starter agent (scenarios + extraction schema) from the file.
close();
}
return (
<Modal
open={open}
onClose={close}
width="md"
title="Bootstrap from a document"
subtitle="Seed a new agent from one representative file"
footer={
<div className="portal-agents__dialog-footer">
<Button variant="ghost" size="sm" onClick={close}>
Cancel
</Button>
<Button size="sm" onClick={bootstrap} disabled={!fileName}>
Bootstrap agent
</Button>
</div>
}
>
<div className="portal-agents__bootstrap">
<p className="portal-agents__bootstrap-lead">
Drop a sample document and we&apos;ll propose scenarios and an
extraction schema you can refine. Nothing is published until you
review it.
</p>
<label className="portal-agents__dropzone">
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg,.tiff"
className="portal-agents__dropzone-input"
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
/>
<span className="portal-agents__dropzone-icon" aria-hidden>
</span>
<span className="portal-agents__dropzone-text">
{fileName ?? "Choose a sample document (PDF or image)"}
</span>
</label>
</div>
</Modal>
);
}
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
const PRO = agentsFor("pro");
const FREE = agentsFor("free");
const meta: Meta<typeof EvalsPanel> = {
title: "Portal/AgentBuilder/EvalsPanel",
component: EvalsPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof EvalsPanel>;
/** High pass-rate agent — bar and pass-rate tile read green. */
export const Passing: Story = {
args: { agent: PRO[0] },
};
/** KYC draft sits below the green band, so the pass-rate tile warns. */
export const BelowTarget: Story = {
args: { agent: PRO[2] },
};
/** Free-tier agent has no golden set — the panel shows the upgrade gate. */
export const NoGoldenSet: Story = {
args: { agent: FREE[0] },
};
@@ -0,0 +1,100 @@
import {
Button,
EmptyState,
ProgressBar,
StatTile,
StatusBadge,
Table,
type TableColumn,
} from "@shared/components";
import type { Agent, EvalCase } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface EvalsPanelProps {
agent: Agent;
}
const COLUMNS: TableColumn<EvalCase>[] = [
{ key: "name", header: "Eval case", render: (c) => c.name },
{
key: "result",
header: "Result",
render: (c) =>
c.passing === null ? (
<span className="portal-agents__muted">not run</span>
) : (
<StatusBadge tone={c.passing ? "success" : "danger"} size="sm">
{c.passing ? "pass" : "fail"}
</StatusBadge>
),
},
{
key: "latency",
header: "Latency",
align: "right",
render: (c) => (
<span className="portal-agents__mono">{c.latencyMs} ms</span>
),
},
];
/** Golden-set pass-rate, the per-case results table, and a run affordance. */
export function EvalsPanel({ agent }: EvalsPanelProps) {
if (agent.evalsTotal === 0) {
return (
<div className="portal-agents__panel">
<EmptyState
title="No golden set yet"
description="Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it."
size="compact"
/>
</div>
);
}
const rate = agent.evalsPassing / agent.evalsTotal;
function runEvals() {
// TODO(backend): POST /v1/agents/{id}/evals/run — kick off a golden-set
// run, then poll for the updated case results.
}
return (
<div className="portal-agents__panel">
<div className="portal-agents__eval-head">
<div className="portal-agents__stat-grid portal-agents__stat-grid--two">
<StatTile
label="Pass rate"
value={`${Math.round(rate * 100)}%`}
tone={rate >= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"}
/>
<StatTile
label="Cases passing"
value={`${agent.evalsPassing} / ${agent.evalsTotal}`}
/>
</div>
<Button size="sm" variant="outline" onClick={runEvals}>
Run evals
</Button>
</div>
<div className="portal-agents__bar-row">
<div className="portal-agents__bar-head">
<span>Golden-set pass rate</span>
<strong>{Math.round(rate * 100)}%</strong>
</div>
<ProgressBar
value={rate}
color={rate >= 0.95 ? "var(--color-green)" : "var(--color-amber)"}
label="Golden-set pass rate"
/>
</div>
<Table<EvalCase>
columns={COLUMNS}
rows={agent.evalCases}
rowKey={(c) => c.id}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
const AGENTS = agentsFor("pro");
const meta: Meta<typeof ScenariosPanel> = {
title: "Portal/AgentBuilder/ScenariosPanel",
component: ScenariosPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ScenariosPanel>;
/** Contract Router ships three scenarios; the add-row stages a fourth locally. */
export const Default: Story = {
args: { agent: AGENTS[0] },
};
/** An agent with a muted scenario (excluded from the eval run). */
export const WithMutedScenario: Story = {
args: { agent: AGENTS[1] },
};
@@ -0,0 +1,108 @@
import { useState } from "react";
import {
Button,
Chip,
FormField,
Input,
StatusBadge,
} from "@shared/components";
import type { Agent, Scenario } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface ScenariosPanelProps {
agent: Agent;
}
/**
* Named test cases describing expected behaviour. Edits live in local state —
* the surface is mock-driven, so adding a row only stages it client-side until
* the submit endpoint exists.
*/
export function ScenariosPanel({ agent }: ScenariosPanelProps) {
// Seed from the agent and re-seed when the selection changes (key prop on the
// builder forces a remount, so a plain useState initialiser is enough).
const [scenarios, setScenarios] = useState<Scenario[]>(agent.scenarios);
const [name, setName] = useState("");
const [expectation, setExpectation] = useState("");
const canAdd = name.trim() !== "" && expectation.trim() !== "";
function addScenario() {
if (!canAdd) return;
const next: Scenario = {
id: `sc-local-${Date.now()}`,
name: name.trim(),
expectation: expectation.trim(),
enabled: true,
};
// TODO(backend): POST /v1/agents/{id}/scenarios { name, expectation } —
// persist the scenario, then replace the optimistic row with the response.
setScenarios((cur) => [...cur, next]);
setName("");
setExpectation("");
}
function toggleEnabled(id: string) {
setScenarios((cur) =>
cur.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s)),
);
}
return (
<div className="portal-agents__panel">
<ul className="portal-agents__scenarios">
{scenarios.map((s) => (
<li key={s.id} className="portal-agents__scenario">
<div className="portal-agents__scenario-text">
<div className="portal-agents__scenario-head">
<strong>{s.name}</strong>
<StatusBadge
tone={s.enabled ? "success" : "neutral"}
size="sm"
showDot={false}
>
{s.enabled ? "in eval" : "muted"}
</StatusBadge>
</div>
<span className="portal-agents__scenario-expect">
{s.expectation}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => toggleEnabled(s.id)}
>
{s.enabled ? "Mute" : "Enable"}
</Button>
</li>
))}
</ul>
<div className="portal-agents__scenario-add">
<Chip tone="blue" size="sm">
Add scenario
</Chip>
<div className="portal-agents__scenario-form">
<FormField label="Name">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Compliance escalation"
/>
</FormField>
<FormField label="Expected behaviour">
<Input
value={expectation}
onChange={(e) => setExpectation(e.target.value)}
placeholder="What the agent should do"
/>
</FormField>
<Button size="sm" onClick={addScenario} disabled={!canAdd}>
Add
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
const AGENTS = agentsFor("enterprise");
const meta: Meta<typeof ToolsPanel> = {
title: "Portal/AgentBuilder/ToolsPanel",
component: ToolsPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ToolsPanel>;
/** Restricted mode with a deny list — the enterprise governance posture. */
export const Restricted: Story = {
args: { agent: AGENTS[0], governanceUnlocked: true },
};
/** Broad access — every tool callable. */
export const Broad: Story = {
args: { agent: AGENTS[1], governanceUnlocked: true },
};
/** Governance locked: the toggle is disabled and explains the upgrade. */
export const GovernanceLocked: Story = {
args: { agent: AGENTS[0], governanceUnlocked: false },
};
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Chip, ToggleSwitch } from "@shared/components";
import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface ToolsPanelProps {
agent: Agent;
/** Restricted-tools governance is an enterprise capability. */
governanceUnlocked: boolean;
}
/**
* Tool-access posture. `broad` grants every tool; `restricted` is allow-by-
* default minus an explicit deny list, picked from the known tool catalogue.
*/
export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
const [mode, setMode] = useState<ToolMode>(agent.toolMode);
const [denied, setDenied] = useState<string[]>(agent.deniedTools);
function setRestricted(on: boolean) {
// TODO(backend): PATCH /v1/agents/{id}/tools { mode, deniedTools } —
// persist the access posture.
setMode(on ? "restricted" : "broad");
}
function toggleDenied(tool: string) {
setDenied((cur) =>
cur.includes(tool) ? cur.filter((t) => t !== tool) : [...cur, tool],
);
}
const restricted = mode === "restricted";
return (
<div className="portal-agents__panel">
<div className="portal-agents__tool-mode">
<ToggleSwitch
checked={restricted}
onChange={setRestricted}
disabled={!governanceUnlocked}
label="Restricted tool access"
description={
governanceUnlocked
? "Allow every tool except the ones you deny below."
: "Tool governance is available on the Enterprise plan."
}
/>
<Chip tone={restricted ? "amber" : "green"} size="sm">
{restricted ? "Restricted" : "Broad access"}
</Chip>
</div>
{restricted && (
<div className="portal-agents__detail-section">
<span className="portal-agents__detail-heading">Denied tools</span>
<p className="portal-agents__hint">
Selected tools are blocked. Everything else stays callable.
</p>
<div className="portal-agents__chips">
{TOOL_CATALOGUE.map((tool) => {
const isDenied = denied.includes(tool);
return (
<Chip
key={tool}
tone={isDenied ? "red" : "neutral"}
size="sm"
onClick={
governanceUnlocked ? () => toggleDenied(tool) : undefined
}
>
{isDenied ? "✕ " : ""}
{tool}
</Chip>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
const PRO = agentsFor("pro");
const meta: Meta<typeof VersionsPanel> = {
title: "Portal/AgentBuilder/VersionsPanel",
component: VersionsPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof VersionsPanel>;
/** Full history with rollback on prior published versions. */
export const FullHistory: Story = {
args: { agent: PRO[0], historyUnlocked: true },
};
/** A draft current version offers a publish action. */
export const DraftAwaitingPublish: Story = {
args: { agent: PRO[2], historyUnlocked: true },
};
/** History locked: only the current version shows, with an upgrade hint. */
export const HistoryLocked: Story = {
args: { agent: PRO[0], historyUnlocked: false },
};
@@ -0,0 +1,99 @@
import { Button, StatusBadge } from "@shared/components";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface VersionsPanelProps {
agent: Agent;
/** Deep version history is an enterprise capability; lower tiers see the gate. */
historyUnlocked: boolean;
}
/** Render an ISO timestamp as a short, locale-stable date. */
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
/** Version history with publish / rollback actions per row. */
export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
// Without governance, only the current version is meaningful to show.
const versions = historyUnlocked
? agent.versions
: agent.versions.slice(0, 1);
const publishedExists = agent.versions.some((v) => v.status === "published");
function publish(version: string) {
void version;
// TODO(backend): POST /v1/agents/{id}/versions/{v}/publish — promote the
// draft to published and demote the previous published version.
}
function rollback(version: string) {
void version;
// TODO(backend): POST /v1/agents/{id}/versions/{v}/rollback — re-publish a
// prior version as the active one.
}
return (
<div className="portal-agents__panel">
<ol className="portal-agents__versions">
{versions.map((v) => {
const isCurrent = v.version === agent.version;
return (
<li key={v.version} className="portal-agents__version">
<div className="portal-agents__version-main">
<div className="portal-agents__version-head">
<code className="portal-agents__version-tag">
{v.version}
</code>
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
{v.status}
</StatusBadge>
{isCurrent && (
<StatusBadge tone="info" size="sm" showDot={false}>
current
</StatusBadge>
)}
</div>
<span className="portal-agents__version-note">{v.note}</span>
<span className="portal-agents__version-meta">
{formatDate(v.createdAt)} · {v.author}
</span>
</div>
<div className="portal-agents__version-actions">
{v.status === "draft" && (
<Button
size="sm"
variant="outline"
onClick={() => publish(v.version)}
>
Publish
</Button>
)}
{v.status === "published" && !isCurrent && (
<Button
size="sm"
variant="ghost"
onClick={() => rollback(v.version)}
>
Roll back
</Button>
)}
</div>
</li>
);
})}
</ol>
{!historyUnlocked && publishedExists && (
<p className="portal-agents__hint">
Full version history and rollback are available on the Enterprise
plan.
</p>
)}
</div>
);
}
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { componentsFor } from "@portal/mocks/sdkComponents";
import { ComponentCard } from "@portal/components/catalogue/ComponentCard";
const PRO = componentsFor("pro");
const GA = PRO.find((c) => c.maturity === "ga")!;
const BETA = PRO.find((c) => c.maturity === "beta")!;
const meta: Meta<typeof ComponentCard> = {
title: "Portal/Components/ComponentCard",
component: ComponentCard,
parameters: { layout: "padded" },
args: { component: GA, unlocked: true, onOpen: () => {} },
};
export default meta;
type Story = StoryObj<typeof ComponentCard>;
export const GeneralAvailability: Story = {};
export const Beta: Story = {
args: { component: BETA },
};
/** Sits above the tier — dimmed with a lock affordance. */
export const Locked: Story = {
args: { component: BETA, unlocked: false },
};
@@ -0,0 +1,72 @@
import { Card, Chip, StatusBadge } from "@shared/components";
import {
type SdkComponent,
MATURITY_META,
formatPrice,
} from "@portal/api/sdkComponents";
import "@portal/views/Components.css";
interface ComponentCardProps {
component: SdkComponent;
/** False when the component sits above the active tier — renders locked. */
unlocked: boolean;
onOpen: (component: SdkComponent) => void;
}
/** A single catalogue tile: name, maturity, description, price and frameworks. */
export function ComponentCard({
component,
unlocked,
onOpen,
}: ComponentCardProps) {
const maturity = MATURITY_META[component.maturity];
return (
<Card
interactive
padding="default"
className={"portal-components__card" + (unlocked ? "" : " is-locked")}
role="button"
tabIndex={0}
aria-label={`Open ${component.name} component`}
onClick={() => onOpen(component)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(component);
}
}}
>
<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}
</StatusBadge>
{!unlocked && (
<span className="portal-components__lock" aria-label="Locked">
🔒
</span>
)}
</div>
<p className="portal-components__card-desc">{component.description}</p>
<div className="portal-components__card-meta">
<span className="portal-components__price">
{formatPrice(component.pricing)}
</span>
<span className="portal-components__pkg">
@stirling/{component.package}
</span>
</div>
<div className="portal-components__frameworks">
{component.frameworks.map((fw) => (
<Chip key={fw} size="sm" tone="neutral">
{fw}
</Chip>
))}
</div>
</Card>
);
}
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { componentsFor } from "@portal/mocks/sdkComponents";
import { ComponentDetailModal } from "@portal/components/catalogue/ComponentDetailModal";
const PRO = componentsFor("pro");
const VIEWER = PRO.find((c) => c.id === "viewer")!;
const TOOLKIT = PRO.find((c) => c.id === "toolkit")!;
const meta: Meta<typeof ComponentDetailModal> = {
title: "Portal/Components/ComponentDetailModal",
component: ComponentDetailModal,
parameters: { layout: "fullscreen" },
args: { component: VIEWER, unlocked: true, onClose: () => {} },
};
export default meta;
type Story = StoryObj<typeof ComponentDetailModal>;
export const Unlocked: Story = {};
/** Enterprise-only component opened on a lower tier — shows the upgrade nudge. */
export const Locked: Story = {
args: { component: TOOLKIT, unlocked: false },
};
export const Closed: Story = {
args: { component: null },
};
@@ -0,0 +1,203 @@
import { useState } from "react";
import {
Banner,
Button,
Chip,
CodeBlock,
Modal,
StatTile,
StatusBadge,
Tabs,
} from "@shared/components";
import {
type SdkComponent,
MATURITY_META,
formatPrice,
} from "@portal/api/sdkComponents";
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
import "@portal/views/Components.css";
type DetailTab = "overview" | "code" | "props" | "pricing";
const TABS: { key: DetailTab; label: string }[] = [
{ key: "overview", label: "Overview" },
{ key: "code", label: "Code" },
{ key: "props", label: "Props / API" },
{ key: "pricing", label: "Pricing" },
];
interface ComponentDetailModalProps {
component: SdkComponent | null;
/** False when the open component sits above the active tier. */
unlocked: boolean;
onClose: () => void;
}
/**
* Detail overlay for a catalogue component: a live-preview placeholder plus
* Overview / Code / Props / Pricing tabs. Locked components swap the install
* CTA for an upgrade nudge.
*/
export function ComponentDetailModal({
component,
unlocked,
onClose,
}: ComponentDetailModalProps) {
const [tab, setTab] = useState<DetailTab>("overview");
// Reset to the first tab whenever a new component is opened.
const open = component !== null;
if (!component) {
return (
<Modal open={false} onClose={onClose} ariaLabel="Component detail" />
);
}
const maturity = MATURITY_META[component.maturity];
const npm = `@stirling/${component.package}`;
return (
<Modal
key={component.id}
open={open}
onClose={() => {
onClose();
setTab("overview");
}}
width="xl"
title={
<span className="portal-components__modal-title">
{component.name}
<StatusBadge tone={maturity.tone} size="sm" showDot={false}>
{maturity.label}
</StatusBadge>
</span>
}
subtitle={npm}
footer={
unlocked ? (
<div className="portal-components__modal-footer">
<span className="portal-components__price">
{formatPrice(component.pricing)}
</span>
<Button
size="sm"
// TODO(backend): open the package quickstart / provision a
// publishable key scoped to this component.
onClick={() => onClose()}
>
Add to project
</Button>
</div>
) : (
<Button
size="sm"
accent="purple"
// TODO(backend): route to the upgrade / contact-sales flow.
onClick={() => onClose()}
>
Upgrade to unlock
</Button>
)
}
>
{!unlocked && (
<Banner
tone="warning"
title="Not available on your plan"
description={`${component.name} is included from the ${component.minTier} plan. Upgrade to embed it.`}
/>
)}
{/* Live-preview sandbox — a styled placeholder until a real host mounts. */}
<div className="portal-components__preview" aria-hidden>
{/* TODO(backend)/host: mount the live <Sandbox> here, booting the
component against a demo document and the dev's publishable key. */}
<span className="portal-components__preview-badge">Live preview</span>
<span className="portal-components__preview-note">
Interactive sandbox renders here
</span>
</div>
<Tabs<DetailTab>
className="portal-components__tabs"
items={TABS}
activeKey={tab}
onChange={setTab}
variant="underline"
ariaLabel="Component detail sections"
/>
<div className="portal-components__tab-body">
{tab === "overview" && (
<div className="portal-components__overview">
<p className="portal-components__overview-desc">
{component.description}
</p>
<div className="portal-components__frameworks">
{component.frameworks.map((fw) => (
<Chip key={fw} size="sm" tone="blue">
{fw}
</Chip>
))}
</div>
<div className="portal-components__stat-grid">
<StatTile label="Maturity" value={maturity.label} />
<StatTile label="Price" value={formatPrice(component.pricing)} />
<StatTile
label="Free quota"
value={
component.pricing.freeQuota > 0
? `${component.pricing.freeQuota.toLocaleString()} / mo`
: "None"
}
/>
<StatTile
label="Embeds (30d)"
value={component.embeds30d.toLocaleString()}
/>
</div>
</div>
)}
{tab === "code" && (
<div className="portal-components__code">
<CodeBlock code={component.install} lang="bash" caption="Install" />
<CodeBlock
code={component.usage}
lang="typescript"
caption="Usage"
/>
</div>
)}
{tab === "props" && <ComponentPropsTable props={component.props} />}
{tab === "pricing" && (
<div className="portal-components__pricing">
<div className="portal-components__stat-grid">
<StatTile
label="Per action"
value={formatPrice(component.pricing)}
/>
<StatTile label="Billed on" value={component.pricing.unit} />
<StatTile
label="Free quota"
value={
component.pricing.freeQuota > 0
? `${component.pricing.freeQuota.toLocaleString()} / mo`
: "None"
}
/>
</div>
<p className="portal-components__pricing-note">
Metered per {component.pricing.unit}. Usage beyond the monthly
free quota is billed to your account and itemised under Usage
&amp; Billing.
</p>
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { componentsFor } from "@portal/mocks/sdkComponents";
import { ComponentGrid } from "@portal/components/catalogue/ComponentGrid";
const meta: Meta<typeof ComponentGrid> = {
title: "Portal/Components/ComponentGrid",
component: ComponentGrid,
parameters: { layout: "padded" },
args: { components: componentsFor("pro"), tier: "pro", onOpen: () => {} },
};
export default meta;
type Story = StoryObj<typeof ComponentGrid>;
export const Pro: Story = {};
/** Free locks every paid component — the whole grid shows upgrade nudges. */
export const Free: Story = {
args: { components: componentsFor("free"), tier: "free" },
};
/** Enterprise unlocks the enterprise-only Beta components. */
export const Enterprise: Story = {
args: { components: componentsFor("enterprise"), tier: "enterprise" },
};
@@ -0,0 +1,30 @@
import { type SdkComponent, isUnlocked } from "@portal/api/sdkComponents";
import type { Tier } from "@portal/contexts/TierContext";
import { ComponentCard } from "@portal/components/catalogue/ComponentCard";
import "@portal/views/Components.css";
interface ComponentGridProps {
components: SdkComponent[];
tier: Tier;
onOpen: (component: SdkComponent) => void;
}
/** Responsive grid of catalogue cards; locks components above the tier. */
export function ComponentGrid({
components,
tier,
onOpen,
}: ComponentGridProps) {
return (
<div className="portal-components__grid">
{components.map((c) => (
<ComponentCard
key={c.id}
component={c}
unlocked={isUnlocked(c, tier)}
onOpen={onOpen}
/>
))}
</div>
);
}
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { componentsFor } from "@portal/mocks/sdkComponents";
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
const VIEWER = componentsFor("pro").find((c) => c.id === "viewer")!;
const meta: Meta<typeof ComponentPropsTable> = {
title: "Portal/Components/ComponentPropsTable",
component: ComponentPropsTable,
parameters: { layout: "padded" },
args: { props: VIEWER.props },
};
export default meta;
type Story = StoryObj<typeof ComponentPropsTable>;
export const Default: Story = {};
@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { Chip, Table, type TableColumn } from "@shared/components";
import type { ComponentProp } from "@portal/api/sdkComponents";
import "@portal/views/Components.css";
interface ComponentPropsTableProps {
props: ComponentProp[];
}
/** Small Props/API reference shown under the detail modal's Props tab. */
export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) {
const columns = useMemo<TableColumn<ComponentProp>[]>(
() => [
{
key: "name",
header: "Prop",
render: (p) => (
<span className="portal-components__prop-name">{p.name}</span>
),
},
{
key: "type",
header: "Type",
render: (p) => (
<code className="portal-components__prop-type">{p.type}</code>
),
},
{
key: "required",
header: "Required",
render: (p) =>
p.required ? (
<Chip size="sm" tone="amber">
required
</Chip>
) : (
<span className="portal-components__muted">optional</span>
),
},
{
key: "description",
header: "Description",
render: (p) => (
<span className="portal-components__prop-desc">{p.description}</span>
),
},
],
[],
);
return (
<Table<ComponentProp>
columns={columns}
rows={rows}
rowKey={(p) => p.name}
/>
);
}
@@ -0,0 +1,40 @@
import { MetricCard, MetricStrip } from "@shared/components";
import type { ComponentsResponse } from "@portal/api/sdkComponents";
/**
* Labels are product copy — they describe what each metric IS, not its value,
* so the strip's structure stays stable across loading / empty / ready states.
* Only values flow from the API.
*/
const KPI_LABELS = [
"Components GA",
"In beta",
"Embeds this month",
"Component spend (MTD)",
] as const;
interface ComponentsSummaryStripProps {
data: ComponentsResponse | null;
loading: boolean;
}
export function ComponentsSummaryStrip({
data,
loading,
}: ComponentsSummaryStripProps) {
const s = loading ? undefined : data?.summary;
const values: (string | number)[] = [
s?.gaCount ?? "—",
s?.betaCount ?? "—",
s ? s.embedsThisMonth.toLocaleString() : "—",
s ? `$${s.spendThisMonth.toLocaleString()}` : "—",
];
return (
<MetricStrip>
{KPI_LABELS.map((label, i) => (
<MetricCard key={label} label={label} value={values[i]} />
))}
</MetricStrip>
);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { documentsFor } from "@portal/mocks/documents";
import { DocumentAudit } from "@portal/components/documents/DocumentAudit";
import "@portal/views/Documents.css";
const DOC = documentsFor("pro")[0];
const meta: Meta<typeof DocumentAudit> = {
title: "Portal/Documents/DocumentAudit",
component: DocumentAudit,
parameters: { layout: "padded" },
args: { doc: DOC },
decorators: [
(S) => (
<div style={{ maxWidth: "36rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentAudit>;
export const Default: Story = {};
/** A document whose lifecycle reached approval. */
export const Approved: Story = {
args: { doc: documentsFor("pro").find((d) => d.status === "processed")! },
};
@@ -0,0 +1,36 @@
import { StatusBadge } from "@shared/components";
import {
DOC_AUDIT_LABEL,
DOC_AUDIT_TONE,
type ReviewDocument,
} from "@portal/api/documents";
/** Lifecycle timeline for a single document, oldest first. */
export function DocumentAudit({ doc }: { doc: ReviewDocument }) {
if (doc.audit.length === 0) {
return <p className="portal-documents__muted">No events recorded yet.</p>;
}
return (
<ol className="portal-documents__timeline">
{doc.audit.map((event) => (
<li key={event.id} className="portal-documents__timeline-item">
<span className="portal-documents__timeline-dot" aria-hidden />
<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]}
</StatusBadge>
<span className="portal-documents__timeline-time">
{event.time}
</span>
</div>
<p className="portal-documents__timeline-detail">{event.detail}</p>
<span className="portal-documents__timeline-actor">
{event.actor}
</span>
</div>
</li>
))}
</ol>
);
}
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { documentsFor } from "@portal/mocks/documents";
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
import "@portal/views/Documents.css";
const ALL = documentsFor("enterprise");
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
const SENSITIVE = ALL.find((d) => d.sensitive)!;
const meta: Meta<typeof DocumentDrawer> = {
title: "Portal/Documents/DocumentDrawer",
component: DocumentDrawer,
parameters: { layout: "fullscreen" },
args: { onClose: () => {} },
};
export default meta;
type Story = StoryObj<typeof DocumentDrawer>;
/** Standard document — all sub-tabs visible, content shown. */
export const Default: Story = {
args: { doc: NON_SENSITIVE },
};
/**
* Sensitive document — opens with the zero-standing-access banner; the
* Extractions tab stays masked until access is requested. On the enterprise
* tier the banner carries the four-eyes note.
*/
export const Sensitive: Story = {
args: { doc: SENSITIVE },
};
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { Drawer, StatusBadge, Tabs, type TabItem } from "@shared/components";
import {
DOCUMENT_STATUS_LABEL,
DOCUMENT_STATUS_TONE,
type ReviewDocument,
} from "@portal/api/documents";
import { useTier } from "@portal/contexts/TierContext";
import { ELEVATION_WINDOW_SECONDS } from "@portal/components/documents/format";
import { DocumentOverview } from "@portal/components/documents/DocumentOverview";
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
import { DocumentAudit } from "@portal/components/documents/DocumentAudit";
import { ElevationBanner } from "@portal/components/documents/ElevationBanner";
type SubTab = "overview" | "extractions" | "audit";
const SUB_TABS: TabItem<SubTab>[] = [
{ key: "overview", label: "Overview" },
{ key: "extractions", label: "Extractions" },
{ key: "audit", label: "Audit" },
];
interface DocumentDrawerProps {
/** Selected document, or null when the drawer is closed. */
doc: ReviewDocument | null;
onClose: () => void;
}
/**
* Detail panel for a queued document. Sub-tabs split overview, extracted
* fields, and the audit timeline. Sensitive documents gate their content
* behind a client-side timed elevation; enterprise adds a four-eyes note.
*/
export function DocumentDrawer({ doc, onClose }: DocumentDrawerProps) {
const { tier } = useTier();
const fourEyes = tier === "enterprise";
const [tab, setTab] = useState<SubTab>("overview");
// Seconds remaining on the active elevation grant; null means no grant.
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
// Reset tab and any active grant whenever a different document opens — a
// grant is scoped to the document it was requested for.
useEffect(() => {
setTab("overview");
setSecondsLeft(null);
}, [doc?.id]);
// Tick the countdown down to expiry, then drop the grant.
useEffect(() => {
if (secondsLeft === null) return;
if (secondsLeft <= 0) {
setSecondsLeft(null);
return;
}
const timer = setTimeout(() => setSecondsLeft((s) => (s ?? 1) - 1), 1000);
return () => clearTimeout(timer);
}, [secondsLeft]);
if (!doc) return null;
function requestAccess() {
// TODO(backend): POST /v1/documents/{id}/elevation — request a time-boxed
// grant (and, on enterprise, trigger the four-eyes peer notification).
// The grant + countdown are simulated client-side until that exists.
setSecondsLeft(ELEVATION_WINDOW_SECONDS);
}
const unlocked = secondsLeft !== null;
return (
<Drawer
open
onClose={onClose}
width="lg"
title={doc.name}
subtitle={`${doc.type} · ${doc.source}`}
>
<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]}
</StatusBadge>
</div>
{doc.sensitive && (
<ElevationBanner
secondsLeft={secondsLeft}
fourEyes={fourEyes}
onRequest={requestAccess}
/>
)}
<Tabs<SubTab>
items={SUB_TABS}
activeKey={tab}
onChange={setTab}
variant="underline"
ariaLabel="Document detail sections"
/>
<div className="portal-documents__drawer-panel">
{tab === "overview" && <DocumentOverview doc={doc} />}
{tab === "extractions" && (
<DocumentExtractions doc={doc} unlocked={unlocked} />
)}
{tab === "audit" && <DocumentAudit doc={doc} />}
</div>
</div>
</Drawer>
);
}
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { documentsFor } from "@portal/mocks/documents";
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
import "@portal/views/Documents.css";
const ALL = documentsFor("enterprise");
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
const SENSITIVE = ALL.find((d) => d.sensitive)!;
const NO_AMOUNT = ALL.find((d) =>
d.extractions.some((e) => e.confidence === 0),
)!;
const meta: Meta<typeof DocumentExtractions> = {
title: "Portal/Documents/DocumentExtractions",
component: DocumentExtractions,
parameters: { layout: "padded" },
args: { doc: NON_SENSITIVE, unlocked: false },
decorators: [
(S) => (
<div style={{ maxWidth: "36rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentExtractions>;
/** Per-field table with mixed confidence tones. */
export const Default: Story = {};
/** A field that failed to extract sits at 0% confidence. */
export const LowConfidence: Story = {
args: { doc: NO_AMOUNT },
};
/** Sensitive doc with no active grant — content stays masked. */
export const Masked: Story = {
args: { doc: SENSITIVE, unlocked: false },
};
/** Same sensitive doc once a timed elevation is active. */
export const Unlocked: Story = {
args: { doc: SENSITIVE, unlocked: true },
};
@@ -0,0 +1,73 @@
import { StatusBadge, Table, type TableColumn } from "@shared/components";
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
import {
confidencePct,
confidenceTone,
} from "@portal/components/documents/format";
const cols: TableColumn<Extraction>[] = [
{
key: "field",
header: "Field",
render: (e) => <span className="portal-documents__field">{e.field}</span>,
},
{
key: "value",
header: "Value",
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
},
{
key: "confidence",
header: "Confidence",
align: "right",
width: "7rem",
render: (e) => (
<StatusBadge
tone={confidenceTone(e.confidence)}
size="sm"
showDot={false}
>
{confidencePct(e.confidence)}
</StatusBadge>
),
},
];
interface DocumentExtractionsProps {
doc: ReviewDocument;
/**
* Whether sensitive content may be shown. A sensitive doc renders its fields
* only once a timed elevation is active; the gate UI itself lives in the
* drawer so it can sit above all sub-tabs.
*/
unlocked: boolean;
}
/** Per-field extraction table, gated behind elevation for sensitive docs. */
export function DocumentExtractions({
doc,
unlocked,
}: DocumentExtractionsProps) {
if (doc.sensitive && !unlocked) {
return (
<div className="portal-documents__masked">
<span className="portal-documents__masked-icon" aria-hidden>
🔒
</span>
<p className="portal-documents__masked-text">
Extracted fields are hidden. Request timed access to view this
document's content.
</p>
</div>
);
}
return (
<Table<Extraction>
columns={cols}
rows={doc.extractions}
rowKey={(e) => e.field}
empty="No fields were extracted from this document."
/>
);
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { documentsFor } from "@portal/mocks/documents";
import { DocumentOverview } from "@portal/components/documents/DocumentOverview";
import "@portal/views/Documents.css";
const DOC = documentsFor("pro")[0];
const meta: Meta<typeof DocumentOverview> = {
title: "Portal/Documents/DocumentOverview",
component: DocumentOverview,
parameters: { layout: "padded" },
args: { doc: DOC },
decorators: [
(S) => (
<div style={{ maxWidth: "36rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentOverview>;
export const Default: Story = {};
@@ -0,0 +1,22 @@
import { StatTile } from "@shared/components";
import {
DOCUMENT_STATUS_LABEL,
type ReviewDocument,
} from "@portal/api/documents";
import { confidencePct } from "@portal/components/documents/format";
/** Key fields for the selected document — status, source, confidence. */
export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
return (
<div className="portal-documents__overview">
<div className="portal-documents__stat-grid">
<StatTile label="Status" value={DOCUMENT_STATUS_LABEL[doc.status]} />
<StatTile label="Type" value={doc.type} />
<StatTile label="Confidence" value={confidencePct(doc.confidence)} />
<StatTile label="Fields extracted" value={doc.fieldsExtracted} />
<StatTile label="Source" value={doc.source} />
<StatTile label="Received" value={doc.time} />
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { summaryFor } from "@portal/mocks/documents";
import { DocumentsSummaryStrip } from "@portal/components/documents/DocumentsSummaryStrip";
import "@portal/views/Documents.css";
const meta: Meta<typeof DocumentsSummaryStrip> = {
title: "Portal/Documents/DocumentsSummaryStrip",
component: DocumentsSummaryStrip,
parameters: { layout: "padded" },
args: { summary: summaryFor("enterprise"), loading: false },
decorators: [
(S) => (
<div style={{ maxWidth: "78rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DocumentsSummaryStrip>;
export const Default: Story = {};
export const Loading: Story = {
args: { summary: null, loading: true },
};
@@ -0,0 +1,46 @@
import { MetricCard, MetricStrip, Skeleton } from "@shared/components";
import type { DocumentsSummary } from "@portal/api/documents";
import { confidencePct } from "@portal/components/documents/format";
interface DocumentsSummaryStripProps {
summary: DocumentsSummary | null;
loading: boolean;
}
/** KPI strip across the top of the review queue. */
export function DocumentsSummaryStrip({
summary,
loading,
}: DocumentsSummaryStripProps) {
if (loading && !summary) {
return (
<MetricStrip>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height="5.5rem" />
))}
</MetricStrip>
);
}
if (!summary) return null;
return (
<MetricStrip>
<MetricCard
label="In queue"
value={summary.totalInQueue.toLocaleString()}
/>
<MetricCard
label="Needs review"
value={summary.needsReview.toLocaleString()}
/>
<MetricCard
label="Avg confidence"
value={confidencePct(summary.avgConfidence)}
/>
<MetricCard
label="Processed today"
value={summary.processedToday.toLocaleString()}
/>
</MetricStrip>
);
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ElevationBanner } from "@portal/components/documents/ElevationBanner";
import { ELEVATION_WINDOW_SECONDS } from "@portal/components/documents/format";
import "@portal/views/Documents.css";
const meta: Meta<typeof ElevationBanner> = {
title: "Portal/Documents/ElevationBanner",
component: ElevationBanner,
parameters: { layout: "padded" },
args: { secondsLeft: null, fourEyes: false, onRequest: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ElevationBanner>;
/** No grant yet — offers the request affordance. */
export const Locked: Story = {};
/** No grant, enterprise four-eyes note. */
export const LockedFourEyes: Story = {
args: { fourEyes: true },
};
/** Active grant counting down. */
export const Granted: Story = {
args: { secondsLeft: ELEVATION_WINDOW_SECONDS - 1 },
};
export const GrantedFourEyes: Story = {
args: { secondsLeft: ELEVATION_WINDOW_SECONDS - 1, fourEyes: true },
};
@@ -0,0 +1,55 @@
import { Banner, Button } from "@shared/components";
import { formatCountdown } from "@portal/components/documents/format";
interface ElevationBannerProps {
/** Seconds left on the active grant, or null when no grant is active. */
secondsLeft: number | null;
/** True for tiers with the four-eyes elevation flow (enterprise). */
fourEyes: boolean;
onRequest: () => void;
}
/**
* Zero-standing-access affordance for a sensitive document. With no active
* grant it offers a "Request access" button; once granted it counts down the
* remaining window. The grant is client-side only — see the view for the
* backend TODO.
*/
export function ElevationBanner({
secondsLeft,
fourEyes,
onRequest,
}: ElevationBannerProps) {
if (secondsLeft !== null) {
return (
<Banner
tone="success"
icon={<span aria-hidden></span>}
title={`Access expires in ${formatCountdown(secondsLeft)}`}
description={
fourEyes
? "Temporary grant — a peer reviewer was notified (four-eyes)."
: "Temporary grant — access is logged and time-boxed."
}
/>
);
}
return (
<Banner
tone="warning"
icon={<span aria-hidden>🔒</span>}
title="Sensitive document"
description={
fourEyes
? "Content is gated by zero-standing-access. Requesting starts a time-boxed grant and notifies a peer reviewer (four-eyes)."
: "Content is gated by zero-standing-access. Requesting starts a time-boxed grant."
}
action={
<Button size="sm" onClick={onRequest}>
Request access
</Button>
}
/>
);
}
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { ReviewQueue } from "@portal/components/documents/ReviewQueue";
import "@portal/views/Documents.css";
const meta: Meta<typeof ReviewQueue> = {
title: "Portal/Documents/ReviewQueue",
component: ReviewQueue,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "78rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ReviewQueue>;
/** Live queue served by the default MSW handler (drives off the toolbar tier). */
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/documents", async () => {
await delay("infinite");
return HttpResponse.json({ summary: {}, documents: [] });
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/documents", () =>
HttpResponse.json({
summary: {
totalInQueue: 0,
needsReview: 0,
avgConfidence: 0,
processedToday: 0,
},
documents: [],
}),
),
],
},
},
};
@@ -0,0 +1,115 @@
import { useMemo, useState } from "react";
import { EmptyState, Skeleton, Tabs, type TabItem } from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchDocuments,
type DocumentStatus,
type DocumentsResponse,
type ReviewDocument,
} from "@portal/api/documents";
import { DocumentsSummaryStrip } from "@portal/components/documents/DocumentsSummaryStrip";
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
import { DocumentDrawer } from "@portal/components/documents/DocumentDrawer";
type QueueFilter = "all" | "needs-review" | "processed" | "archived";
/** Which document statuses each filter pill admits. */
const FILTER_STATUSES: Record<QueueFilter, DocumentStatus[] | null> = {
all: null,
// "Needs review" surfaces both routed-for-review and flagged docs — the two
// states that demand a human decision.
"needs-review": ["needs-review", "flagged"],
processed: ["processed"],
archived: ["archived"],
};
function countFor(docs: ReviewDocument[], filter: QueueFilter): number {
const statuses = FILTER_STATUSES[filter];
if (statuses === null) return docs.length;
return docs.filter((d) => statuses.includes(d.status)).length;
}
/**
* The review/approval queue: KPI strip, status filter pills, the document
* stream table, and a detail drawer. The primary Documents surface.
*/
export function ReviewQueue() {
const { tier } = useTier();
const state = useAsync<DocumentsResponse>(() => fetchDocuments(tier), [tier]);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const [filter, setFilter] = useState<QueueFilter>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const documents = useMemo(() => data?.documents ?? [], [data]);
const rows = useMemo(() => {
const statuses = FILTER_STATUSES[filter];
if (statuses === null) return documents;
return documents.filter((d) => statuses.includes(d.status));
}, [documents, filter]);
const selected = documents.find((d) => d.id === selectedId) ?? null;
const filterItems: TabItem<QueueFilter>[] = [
{ key: "all", label: "All", count: countFor(documents, "all") },
{
key: "needs-review",
label: "Needs review",
count: countFor(documents, "needs-review"),
},
{
key: "processed",
label: "Processed",
count: countFor(documents, "processed"),
},
{
key: "archived",
label: "Archived",
count: countFor(documents, "archived"),
},
];
return (
<div className="portal-documents__queue">
<DocumentsSummaryStrip
summary={data?.summary ?? null}
loading={loading}
/>
<Tabs<QueueFilter>
items={filterItems}
activeKey={filter}
onChange={setFilter}
variant="pill"
ariaLabel="Filter documents by status"
/>
{isLoading && (
<div className="portal-documents__table-skeleton" aria-hidden>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{isEmpty && (
<EmptyState
title="No documents in the queue"
description="As sources feed documents into your pipelines they'll appear here for review."
/>
)}
{!isLoading && !isEmpty && (
<ReviewQueueTable
documents={rows}
onRowClick={(d) => setSelectedId(d.id)}
/>
)}
<DocumentDrawer doc={selected} onClose={() => setSelectedId(null)} />
</div>
);
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { documentsFor } from "@portal/mocks/documents";
import { ReviewQueueTable } from "@portal/components/documents/ReviewQueueTable";
import "@portal/views/Documents.css";
const DOCS = documentsFor("enterprise");
const meta: Meta<typeof ReviewQueueTable> = {
title: "Portal/Documents/ReviewQueueTable",
component: ReviewQueueTable,
parameters: { layout: "padded" },
args: { documents: DOCS, onRowClick: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "78rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ReviewQueueTable>;
export const Default: Story = {};
/** Only the rows demanding a decision. */
export const NeedsReview: Story = {
args: {
documents: DOCS.filter(
(d) => d.status === "needs-review" || d.status === "flagged",
),
},
};
export const Empty: Story = {
args: { documents: [] },
};
@@ -0,0 +1,112 @@
import { useMemo } from "react";
import {
ProgressBar,
StatusBadge,
Table,
type TableColumn,
} from "@shared/components";
import {
DOCUMENT_STATUS_LABEL,
DOCUMENT_STATUS_TONE,
type ReviewDocument,
} from "@portal/api/documents";
import {
confidencePct,
confidenceTone,
} from "@portal/components/documents/format";
interface ReviewQueueTableProps {
documents: ReviewDocument[];
onRowClick: (doc: ReviewDocument) => void;
}
/** The document stream — one row per document awaiting a review decision. */
export function ReviewQueueTable({
documents,
onRowClick,
}: ReviewQueueTableProps) {
const columns = useMemo<TableColumn<ReviewDocument>[]>(
() => [
{
key: "name",
header: "Name",
render: (d) => (
<div className="portal-documents__name-cell">
<span className="portal-documents__name">{d.name}</span>
{d.sensitive && (
<span
className="portal-documents__lock"
title="Sensitive — access required"
aria-label="Sensitive"
>
🔒
</span>
)}
</div>
),
},
{ key: "type", header: "Type", render: (d) => d.type },
{
key: "status",
header: "Status",
render: (d) => (
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
{DOCUMENT_STATUS_LABEL[d.status]}
</StatusBadge>
),
},
{
key: "source",
header: "Source",
render: (d) => (
<span className="portal-documents__muted">{d.source}</span>
),
},
{
key: "confidence",
header: "Confidence",
width: "9rem",
render: (d) => (
<div className="portal-documents__confidence">
<ProgressBar value={d.confidence} height={6} />
<span
className={`portal-documents__confidence-pct portal-documents__confidence-pct--${confidenceTone(
d.confidence,
)}`}
>
{confidencePct(d.confidence)}
</span>
</div>
),
},
{
key: "fields",
header: "Fields",
align: "right",
render: (d) => (
<span className="portal-documents__mono">{d.fieldsExtracted}</span>
),
},
{
key: "time",
header: "Time",
align: "right",
render: (d) => (
<span className="portal-documents__muted">{d.time}</span>
),
},
],
[],
);
return (
<Table<ReviewDocument>
className="portal-documents__table"
columns={columns}
rows={documents}
rowKey={(d) => d.id}
onRowClick={onRowClick}
empty="No documents match this filter."
/>
);
}
@@ -0,0 +1,28 @@
import type { StatusTone } from "@shared/components";
/** Format a 01 confidence fraction as a whole-percent string. */
export function confidencePct(n: number): string {
return `${Math.round(n * 100)}%`;
}
/**
* Tone for a confidence value. The bands match the review thresholds: below
* 60% is unreliable (danger), 6085% warrants a glance (warning), above is
* trusted (success).
*/
export function confidenceTone(n: number): StatusTone {
if (n < 0.6) return "danger";
if (n < 0.85) return "warning";
return "success";
}
/** Window a freshly granted elevation stays valid, in seconds. */
export const ELEVATION_WINDOW_SECONDS = 15 * 60;
/** Format remaining elevation seconds as M:SS for the countdown banner. */
export function formatCountdown(totalSeconds: number): string {
const safe = Math.max(0, totalSeconds);
const minutes = Math.floor(safe / 60);
const seconds = safe % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CredentialRotationCard } from "@portal/components/editor-admin/CredentialRotationCard";
import "@portal/views/EditorAdmin.css";
const meta: Meta<typeof CredentialRotationCard> = {
title: "Portal/EditorAdmin/CredentialRotationCard",
component: CredentialRotationCard,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof CredentialRotationCard>;
export const Default: Story = {
args: {
serviceToken: {
masked: "svc_live_••••••••••••7b21",
lastRotated: "34 days ago",
},
},
};
/** Press "Rotate" in the story to see the post-rotation warning banner. */
export const RecentlyRotated: Story = {
args: {
serviceToken: {
masked: "svc_live_••••••••••••7b21",
lastRotated: "2 days ago",
},
},
};
@@ -0,0 +1,66 @@
import { useState } from "react";
import { Banner, Button, Card, StatTile } from "@shared/components";
import type { DeploymentSummary } from "@portal/api/editorDeploy";
interface Props {
serviceToken: DeploymentSummary["serviceToken"];
}
/**
* Rotation control for the deployment's service token — the credential
* instances use to authenticate back to the org. Rotation is a demo shell:
* it flashes a warning that running instances must pick up the new value, but
* there's no submit endpoint yet.
*/
export function CredentialRotationCard({ serviceToken }: Props) {
const [rotating, setRotating] = useState(false);
const [rotated, setRotated] = useState(false);
function rotate() {
// TODO(backend): POST /v1/editor/deployment/rotate → mints a new service
// token and revokes the old one after a grace window.
setRotating(true);
setTimeout(() => {
setRotating(false);
setRotated(true);
}, 700);
}
return (
<Card padding="default" className="portal-editor__panel">
<div className="portal-editor__panel-head">
<div>
<h3 className="portal-editor__panel-title">Service token</h3>
<p className="portal-editor__panel-sub">
Instances authenticate to the org with this credential. Rotate it on
a schedule or immediately after a suspected leak.
</p>
</div>
</div>
<div className="portal-editor__token-row">
<StatTile label="Current token" value={serviceToken.masked} />
<StatTile label="Last rotated" value={serviceToken.lastRotated} />
</div>
{rotated && (
<Banner
tone="warning"
title="Rotate running instances"
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."
/>
)}
<div className="portal-editor__panel-actions">
<Button
variant="outline"
accent="amber"
loading={rotating}
onClick={rotate}
>
Rotate service token
</Button>
</div>
</Card>
);
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
import { DeploymentSummaryStrip } from "@portal/components/editor-admin/DeploymentSummaryStrip";
import "@portal/views/EditorAdmin.css";
const meta: Meta<typeof DeploymentSummaryStrip> = {
title: "Portal/EditorAdmin/DeploymentSummaryStrip",
component: DeploymentSummaryStrip,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof DeploymentSummaryStrip>;
export const Pro: Story = {
args: { summary: buildEditorDeploymentResponse("pro").summary },
};
export const Enterprise: Story = {
args: { summary: buildEditorDeploymentResponse("enterprise").summary },
};
export const Loading: Story = {
args: { loading: true },
};
@@ -0,0 +1,35 @@
import { MetricCard, MetricStrip, Skeleton } from "@shared/components";
import type { DeploymentSummary } from "@portal/api/editorDeploy";
interface Props {
summary?: DeploymentSummary;
loading?: boolean;
}
/** Top-of-page metric strip summarising the org's Editor deployment health. */
export function DeploymentSummaryStrip({ summary, loading }: Props) {
if (loading || !summary) {
return (
<MetricStrip>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height="5.5rem" />
))}
</MetricStrip>
);
}
return (
<MetricStrip>
{summary.metrics.map((m) => (
<MetricCard
key={m.label}
label={m.label}
value={m.value}
delta={m.delta}
deltaDirection={m.deltaDirection}
description={m.description}
/>
))}
</MetricStrip>
);
}
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
import { TierProvider } from "@portal/contexts/TierContext";
import { DeploymentTargets } from "@portal/components/editor-admin/DeploymentTargets";
import "@portal/views/EditorAdmin.css";
const meta: Meta<typeof DeploymentTargets> = {
title: "Portal/EditorAdmin/DeploymentTargets",
component: DeploymentTargets,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof DeploymentTargets>;
/** Free: only Managed Cloud runs; Docker + K8s are locked behind a paywall. */
export const Free: Story = {
args: { targets: buildEditorDeploymentResponse("free").targets },
decorators: [
(S) => (
<TierProvider initialTier="free">
<S />
</TierProvider>
),
],
};
/** Pro: Docker running, Kubernetes available but not yet deployed. */
export const Pro: Story = {
args: { targets: buildEditorDeploymentResponse("pro").targets },
decorators: [
(S) => (
<TierProvider initialTier="pro">
<S />
</TierProvider>
),
],
};
export const Enterprise: Story = {
args: { targets: buildEditorDeploymentResponse("enterprise").targets },
decorators: [
(S) => (
<TierProvider initialTier="enterprise">
<S />
</TierProvider>
),
],
};
@@ -0,0 +1,102 @@
import { Button, Card, CodeBlock, StatusBadge } from "@shared/components";
import { TARGET_META, type DeploymentTarget } from "@portal/api/editorDeploy";
import { useTier } from "@portal/contexts/TierContext";
const STATE_BADGE: Record<
DeploymentTarget["state"],
{ label: string; tone: "success" | "info" | "neutral" }
> = {
running: { label: "Running", tone: "success" },
available: { label: "Available", tone: "info" },
locked: { label: "Locked", tone: "neutral" },
};
/** Upgrade-nudge copy for a target gated behind a higher tier. */
function lockCopy(target: DeploymentTarget): string {
return target.requiresTier === "enterprise"
? "On-prem and Kubernetes self-hosting are part of Enterprise."
: "Self-hosting with Docker and Kubernetes unlocks on a paid plan.";
}
interface Props {
targets: DeploymentTarget[];
/** Invoked from a locked target's upgrade nudge. */
onUpgrade?: () => void;
}
/**
* The three deployment targets (Managed Cloud / Docker / Kubernetes). Unlocked
* targets show their install/run snippet; locked ones swap it for an upgrade
* nudge so the value of the higher tier is visible inline.
*/
export function DeploymentTargets({ targets, onUpgrade }: Props) {
const { tier } = useTier();
return (
<div className="portal-editor__targets">
{targets.map((t) => {
const meta = TARGET_META[t.kind];
const badge = STATE_BADGE[t.state];
const locked = t.state === "locked";
return (
<Card
key={t.kind}
padding="default"
className="portal-editor__target"
>
<div className="portal-editor__target-head">
<span
className={`portal-editor__target-icon portal-editor__target-icon--${meta.tone}`}
aria-hidden
>
{meta.icon}
</span>
<div className="portal-editor__target-titles">
<h3 className="portal-editor__target-name">{t.label}</h3>
<p className="portal-editor__target-tagline">{t.tagline}</p>
</div>
<StatusBadge
tone={badge.tone}
size="sm"
pulse={t.state === "running"}
>
{badge.label}
</StatusBadge>
</div>
{t.state === "running" && (
<p className="portal-editor__target-meta">
v{t.runningVersion} · {t.instanceCount}{" "}
{t.instanceCount === 1 ? "instance" : "instances"}
</p>
)}
{locked ? (
<div className="portal-editor__lock">
<p className="portal-editor__lock-copy">{lockCopy(t)}</p>
<Button
size="sm"
variant="outline"
accent={t.requiresTier === "enterprise" ? "purple" : "blue"}
onClick={onUpgrade}
disabled={tier === "enterprise"}
>
{t.requiresTier === "enterprise"
? "Talk to sales"
: "Upgrade plan"}
</Button>
</div>
) : (
<CodeBlock
code={t.snippet}
lang={t.snippetLang}
caption={t.label}
maxHeight={180}
/>
)}
</Card>
);
})}
</div>
);
}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
import { InstanceHealthTable } from "@portal/components/editor-admin/InstanceHealthTable";
import "@portal/views/EditorAdmin.css";
const meta: Meta<typeof InstanceHealthTable> = {
title: "Portal/EditorAdmin/InstanceHealthTable",
component: InstanceHealthTable,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof InstanceHealthTable>;
/** Pro: Managed Cloud + two Docker edge nodes, one on a stale version. */
export const Pro: Story = {
args: { instances: buildEditorDeploymentResponse("pro").instances },
};
/** Enterprise: adds the K8s fleet and an offline air-gapped node. */
export const Enterprise: Story = {
args: { instances: buildEditorDeploymentResponse("enterprise").instances },
};
export const Empty: Story = {
args: { instances: [] },
};
@@ -0,0 +1,92 @@
import {
Card,
Chip,
EmptyState,
StatusBadge,
Table,
type TableColumn,
} from "@shared/components";
import {
INSTANCE_STATUS_LABEL,
INSTANCE_STATUS_TONE,
TARGET_META,
type EditorInstance,
} from "@portal/api/editorDeploy";
const TARGET_LABEL: Record<EditorInstance["target"], string> = {
cloud: "Cloud",
docker: "Docker",
kubernetes: "K8s",
};
const cols: TableColumn<EditorInstance>[] = [
{
key: "host",
header: "Host",
render: (i) => (
<div className="portal-editor__cell-stack">
<span className="portal-editor__cell-strong">{i.host}</span>
<span className="portal-editor__cell-muted">
<Chip size="sm" tone={TARGET_META[i.target].tone}>
{TARGET_LABEL[i.target]}
</Chip>
</span>
</div>
),
},
{
key: "version",
header: "Version",
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
},
{
key: "region",
header: "Region",
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
},
{
key: "status",
header: "Status",
render: (i) => (
<StatusBadge
tone={INSTANCE_STATUS_TONE[i.status]}
size="sm"
pulse={i.status === "healthy"}
>
{INSTANCE_STATUS_LABEL[i.status]}
</StatusBadge>
),
},
{
key: "lastSeen",
header: "Last seen",
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
},
{
key: "activeUsers",
header: "Active users",
align: "right",
render: (i) => <span className="portal-editor__mono">{i.activeUsers}</span>,
},
];
interface Props {
instances: EditorInstance[];
}
/** Live health for every Editor instance reporting in to the org. */
export function InstanceHealthTable({ instances }: Props) {
return (
<Card padding="none">
{instances.length === 0 ? (
<EmptyState
size="compact"
title="No instances reporting"
description="Deploy a target and pair it to see live instance health here."
/>
) : (
<Table columns={cols} rows={instances} rowKey={(i) => i.id} />
)}
</Card>
);
}
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OfflineActivationCard } from "@portal/components/editor-admin/OfflineActivationCard";
import "@portal/views/EditorAdmin.css";
const meta: Meta<typeof OfflineActivationCard> = {
title: "Portal/EditorAdmin/OfflineActivationCard",
component: OfflineActivationCard,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof OfflineActivationCard>;
/** Enterprise: bundle generation is live. */
export const Available: Story = {
args: { available: true },
};
/** Free / Pro: locked behind an upgrade nudge. */
export const Locked: Story = {
args: { available: false },
};
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Banner, Button, Card } from "@shared/components";
interface Props {
/** Enterprise-only. When false the card renders an upgrade nudge. */
available: boolean;
onUpgrade?: () => void;
}
/**
* Air-gapped / offline activation. Networks with no outbound path can't pair
* live, so this generates a signed activation bundle to carry in by hand.
* Enterprise-only; lower tiers see an upgrade nudge. Generation is a demo
* shell with no submit endpoint yet.
*/
export function OfflineActivationCard({ available, onUpgrade }: Props) {
const [generating, setGenerating] = useState(false);
const [generated, setGenerated] = useState(false);
function generate() {
// TODO(backend): POST /v1/editor/deployment/offline-bundle → streams a
// signed .stirlingpkg activation bundle for an air-gapped install.
setGenerating(true);
setTimeout(() => {
setGenerating(false);
setGenerated(true);
}, 900);
}
return (
<Card padding="default" accent="purple" className="portal-editor__panel">
<div className="portal-editor__panel-head">
<div>
<h3 className="portal-editor__panel-title">
Air-gapped activation
<span className="portal-editor__enterprise-tag">Enterprise</span>
</h3>
<p className="portal-editor__panel-sub">
Generate a signed activation bundle for an offline or on-prem
install with no outbound network path. Transfer it to the instance
and apply it during first-run setup.
</p>
</div>
</div>
{!available ? (
<div className="portal-editor__lock">
<p className="portal-editor__lock-copy">
Offline and on-prem activation is part of Enterprise.
</p>
<Button
variant="outline"
accent="purple"
size="sm"
onClick={onUpgrade}
>
Talk to sales
</Button>
</div>
) : (
<>
{generated && (
<Banner
tone="success"
title="Bundle ready"
description="activation-acme-3.2.1.stirlingpkg is signed and ready to transfer. It activates one instance and expires in 14 days."
/>
)}
<div className="portal-editor__panel-actions">
<Button
variant="outline"
accent="purple"
loading={generating}
onClick={generate}
>
Generate offline bundle
</Button>
</div>
</>
)}
</Card>
);
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildEditorDeploymentResponse } from "@portal/mocks/editorDeploy";
import { PairingPanel } from "@portal/components/editor-admin/PairingPanel";
import "@portal/views/EditorAdmin.css";
const meta: Meta<typeof PairingPanel> = {
title: "Portal/EditorAdmin/PairingPanel",
component: PairingPanel,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof PairingPanel>;
/** Pro: token + short code usable, IaC locked behind Enterprise. */
export const Pro: Story = {
args: { pairings: buildEditorDeploymentResponse("pro").pairings },
};
/** Enterprise: all three methods unlocked, including the IaC module snippet. */
export const Enterprise: Story = {
args: { pairings: buildEditorDeploymentResponse("enterprise").pairings },
};
@@ -0,0 +1,101 @@
import { useState } from "react";
import { Button, Card, Chip, CodeBlock } from "@shared/components";
import type { PairingMethod, PairingOption } from "@portal/api/editorDeploy";
const METHOD_ICON: Record<PairingMethod, string> = {
token: "🔑",
shortcode: "📺",
iac: "🧱",
};
interface Props {
pairings: PairingOption[];
onUpgrade?: () => void;
}
/**
* Pairing options for connecting a self-hosted editor to the org — a long-lived
* pairing token, a TV-style short code, and IaC provisioning. The
* generate/rotate affordance is a demo shell: it shows transient feedback but
* has no submit endpoint yet.
*/
export function PairingPanel({ pairings, onUpgrade }: Props) {
// Tracks which option just got a (mock) rotate so we can flash confirmation.
const [rotated, setRotated] = useState<PairingMethod | null>(null);
function rotate(method: PairingMethod) {
// TODO(backend): POST /v1/editor/pairings { method } → returns a freshly
// minted token / short code / IaC reference.
setRotated(method);
setTimeout(() => setRotated((m) => (m === method ? null : m)), 1800);
}
return (
<div className="portal-editor__pairings">
{pairings.map((p) => {
const isCode = p.method === "iac";
return (
<Card
key={p.method}
padding="default"
className="portal-editor__pairing"
>
<div className="portal-editor__pairing-head">
<span className="portal-editor__pairing-icon" aria-hidden>
{METHOD_ICON[p.method]}
</span>
<div className="portal-editor__pairing-titles">
<h3 className="portal-editor__pairing-name">{p.label}</h3>
<p className="portal-editor__pairing-desc">{p.description}</p>
</div>
</div>
{p.locked ? (
<div className="portal-editor__lock">
<p className="portal-editor__lock-copy">
IaC provisioning is part of Enterprise.
</p>
<Button
size="sm"
variant="outline"
accent="purple"
onClick={onUpgrade}
>
Talk to sales
</Button>
</div>
) : (
<>
{isCode ? (
<CodeBlock code={p.value} lang="plain" maxHeight={80} />
) : (
<div className="portal-editor__pairing-value">
<code>{p.value}</code>
</div>
)}
<div className="portal-editor__pairing-foot">
{p.expires && (
<Chip size="sm" tone="neutral">
{p.expires}
</Chip>
)}
<Button
size="sm"
variant="outline"
onClick={() => rotate(p.method)}
>
{rotated === p.method
? "Generated ✓"
: p.method === "shortcode"
? "Generate new code"
: "Rotate"}
</Button>
</div>
</>
)}
</Card>
);
})}
</div>
);
}
+43
View File
@@ -200,3 +200,46 @@ export function SendIcon(props: IconProps) {
</Svg>
);
}
export function UsersIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Svg>
);
}
export function PoliciesIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</Svg>
);
}
export function ComponentsIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</Svg>
);
}
export function AgentBuilderIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="11" width="18" height="10" rx="2" />
<circle cx="12" cy="5" r="2" />
<path d="M12 7v4" />
<line x1="8" y1="16" x2="8" y2="16" />
<line x1="16" y1="16" x2="16" y2="16" />
</Svg>
);
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ModelsTab } from "@portal/components/infrastructure/ModelsTab";
import "@portal/views/Infrastructure.css";
// Data is served by the registered MSW infrastructure handler; the tier global
// (toolbar) drives which catalogue + routing slice each story renders.
const meta = {
title: "Infrastructure/ModelsTab",
component: ModelsTab,
parameters: { layout: "padded" },
} satisfies Meta<typeof ModelsTab>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Pro: full managed catalogue plus routing control. */
export const Pro: Story = {
globals: { tier: "pro" },
};
/** Free: two managed models, no routing (upgrade nudge). */
export const Free: Story = {
globals: { tier: "free" },
};
/** Enterprise: adds bring-your-own / on-prem models and per-region pinning. */
export const Enterprise: Story = {
globals: { tier: "enterprise" },
};
@@ -0,0 +1,244 @@
import {
Banner,
Card,
Chip,
EmptyState,
MetricCard,
ProgressBar,
Select,
StatusBadge,
Table,
type SelectOption,
type TableColumn,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchModels,
type ModelEntry,
type ModelsResponse,
type RoutingRule,
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import {
MODEL_LABEL,
MODEL_PROVIDER_LABEL,
MODEL_TONE,
MODEL_TYPE_LABEL,
MODEL_TYPE_TONE,
modelCost,
pct,
} from "@portal/components/infrastructure/infraFormat";
const modelCols: TableColumn<ModelEntry>[] = [
{
key: "name",
header: "Model",
render: (m) => (
<div className="portal-infra__cell-stack">
<span className="portal-infra__cell-strong">{m.name}</span>
<Chip tone="neutral" size="sm">
{MODEL_PROVIDER_LABEL[m.provider]}
</Chip>
</div>
),
},
{
key: "type",
header: "Type",
render: (m) => (
<Chip tone={MODEL_TYPE_TONE[m.type]} size="sm">
{MODEL_TYPE_LABEL[m.type]}
</Chip>
),
},
{
key: "status",
header: "Status",
render: (m) => (
<StatusBadge
tone={MODEL_TONE[m.status]}
size="sm"
pulse={m.status === "active"}
>
{MODEL_LABEL[m.status]}
</StatusBadge>
),
},
{
key: "load",
header: "Load",
width: "9rem",
render: (m) => (
<div className="portal-infra__load">
<ProgressBar value={m.load} thresholded height={6} />
<span className="portal-infra__load-pct">{pct(m.load)}</span>
</div>
),
},
{
key: "latency",
header: "Latency",
align: "right",
render: (m) => <span className="portal-infra__mono">{m.latencyMs} ms</span>,
},
{
key: "cost",
header: "Cost",
align: "right",
render: (m) => (
<span className="portal-infra__mono">
{modelCost(m.cost, m.costUnit)}
</span>
),
},
{
key: "version",
header: "Version",
render: (m) => <code className="portal-infra__cell-code">{m.version}</code>,
},
];
export function ModelsTab() {
const { tier } = useTier();
const state = useAsync<ModelsResponse>(() => fetchModels(tier), [tier]);
const { data } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
// Free has no routing control: the catalogue is read-only and the routing
// table is replaced by an upgrade nudge.
const canRoute = tier !== "free";
// Routing overrides are interactive but unbacked — assigning a model just
// moves local UI state until the routing endpoint exists.
// TODO(backend): PUT /v1/infrastructure/models/routing { rules }
const modelOptions: SelectOption[] =
data?.models
.filter((m) => m.status !== "disabled")
.map((m) => ({ value: m.id, label: m.name })) ?? [];
const routingCols: TableColumn<RoutingRule>[] = [
{
key: "operation",
header: "Operation",
render: (r) => (
<div className="portal-infra__cell-stack">
<span className="portal-infra__cell-strong">{r.operation}</span>
{r.isDefault && (
<Chip tone="blue" size="sm">
Default
</Chip>
)}
</div>
),
},
{ key: "docType", header: "Document type", render: (r) => r.docType },
{
key: "modelId",
header: "Routed to",
width: "16rem",
render: (r) => (
<Select
inputSize="sm"
options={modelOptions}
defaultValue={r.modelId}
aria-label={`Model for ${r.operation}`}
/>
),
},
];
return (
<div className="portal-infra__stack">
<SectionHeader
title="Models"
sub="The model catalogue and routing that powers document processing across your workspace."
/>
{data && (
<section className="portal-infra__metrics">
<MetricCard label="Active models" value={data.summary.activeModels} />
<MetricCard
label="Avg latency"
value={`${data.summary.avgLatencyMs} ms`}
/>
<MetricCard
label="Monthly model spend"
value={
data.summary.monthlySpend > 0
? `$${data.summary.monthlySpend.toLocaleString()}`
: "Included"
}
/>
</section>
)}
<section>
<SectionHeader
title="Catalogue"
sub={
tier === "enterprise"
? "Managed, bring-your-own, and on-prem models — with per-region pinning available."
: "Managed models available to your workspace, with live latency and cost."
}
/>
<Card padding="none">
{isLoading && <TableSkeleton rows={4} cols={7} />}
{isEmpty && (
<EmptyState
size="compact"
title="No models available"
description="Models in your workspace's catalogue appear here."
/>
)}
{!isEmpty && data && data.models.length > 0 && (
<Table
columns={modelCols}
rows={data.models}
rowKey={(m) => m.id}
/>
)}
</Card>
</section>
{tier === "enterprise" && (
<Banner
tone="info"
title="Bring your own model"
description="Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing."
/>
)}
<section>
<SectionHeader
title="Routing rules"
sub={
canRoute
? "Which model handles each operation. The default applies when no narrower rule matches."
: "Route operations to specific models — available on paid plans."
}
/>
{canRoute ? (
<Card padding="none">
{isLoading && <TableSkeleton rows={4} cols={3} />}
{!isEmpty && data && (
<Table
columns={routingCols}
rows={data.routing}
rowKey={(r) => r.id}
empty="No routing rules configured."
/>
)}
</Card>
) : (
<Banner
tone="info"
title="Model routing is a paid feature"
description="Upgrade to Pro to control which model handles each operation and document type."
/>
)}
</section>
</div>
);
}
@@ -20,6 +20,18 @@ type Story = StoryObj<typeof SecurityTab>;
export const Default: Story = {};
// Enterprise unlocks HYOK key custody (with a live rotate affordance) and the
// full attested compliance set, including PCI in-scope.
export const Enterprise: Story = {
globals: { tier: "enterprise" },
};
// Free runs on Stirling-managed keys (rotate disabled, upgrade nudge) and a
// trimmed attestation set with HIPAA/PCI not-applicable.
export const Free: Story = {
globals: { tier: "free" },
};
export const Loading: Story = {
parameters: {
msw: {
@@ -1,7 +1,9 @@
import { useState } from "react";
import {
Banner,
Button,
Card,
Chip,
EmptyState,
RadioGroup,
Skeleton,
@@ -20,8 +22,12 @@ import {
} from "@portal/api/infrastructure";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import {
ATTESTATION_LABEL,
ATTESTATION_TONE,
CERT_LABEL,
CERT_TONE,
KEY_MODE_LABEL,
KEY_MODE_TONE,
} from "@portal/components/infrastructure/infraFormat";
const ACCESS_OPTS: RadioOption<AccessPolicy>[] = [
@@ -146,6 +152,74 @@ export function SecurityTab() {
</Card>
</section>
<section>
<SectionHeader
title="Encryption key management"
sub="Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate."
/>
<Card padding="loose" className="portal-infra__keymgmt">
<div className="portal-infra__keymgmt-head">
<div className="portal-infra__keymgmt-title">
<span className="portal-infra__cell-strong">
{data.keyManagement.provider}
</span>
<StatusBadge
tone={KEY_MODE_TONE[data.keyManagement.mode]}
size="sm"
>
{KEY_MODE_LABEL[data.keyManagement.mode]}
</StatusBadge>
</div>
{/* Rotation is a privileged backend action; disabled where Stirling
holds the keys (managed tiers can't rotate customer keys). */}
<Button
variant="outline"
size="sm"
disabled={!data.keyManagement.customerManaged}
onClick={() => {
// TODO(backend): POST /v1/infrastructure/security/keys/rotate
}}
>
Rotate key
</Button>
</div>
<dl className="portal-infra__kv">
<div className="portal-infra__kv-wide">
<dt>Key identifier</dt>
<dd>
<code className="portal-infra__cell-code">
{data.keyManagement.keyId}
</code>
</dd>
</div>
<div>
<dt>Algorithm</dt>
<dd className="portal-infra__mono">
{data.keyManagement.algorithm}
</dd>
</div>
<div>
<dt>Last rotated</dt>
<dd>{data.keyManagement.lastRotated}</dd>
</div>
<div>
<dt>Rotation policy</dt>
<dd>{data.keyManagement.rotationPolicy}</dd>
</div>
</dl>
{!data.keyManagement.customerManaged && (
<Banner
tone="info"
className="portal-infra__banner"
title="Keys are managed by Stirling on your plan"
description="Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS."
/>
)}
</Card>
</section>
<section>
<SectionHeader
title="Compliance"
@@ -166,6 +240,45 @@ export function SecurityTab() {
</div>
</section>
<section>
<SectionHeader
title="Compliance attestations"
sub="Framework-by-framework audit posture, with reports available on attested controls."
/>
<div className="portal-infra__attestations">
{data.attestations.map((a) => (
<Card
key={a.id}
padding="default"
className="portal-infra__attestation"
>
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{a.name}</span>
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
{ATTESTATION_LABEL[a.status]}
</StatusBadge>
</div>
<Chip tone="neutral" size="sm">
{a.framework}
</Chip>
<p className="portal-infra__cert-detail">{a.detail}</p>
{a.reportUrl ? (
<a
className="portal-infra__attestation-link"
href={a.reportUrl}
// TODO(backend): GET /v1/infrastructure/security/reports/:id
onClick={(e) => e.preventDefault()}
>
View report
</a>
) : (
<span className="portal-infra__muted">No report available</span>
)}
</Card>
))}
</div>
</section>
<section>
<SectionHeader
title="IP allowlist"
@@ -1,10 +1,16 @@
import type { StatusTone } from "@shared/components";
import type { ChipTone, StatusTone } from "@shared/components";
import type {
ApiKeyStatus,
AttestationStatus,
AuditCategory,
AuditStatus,
CertStatus,
DeploymentStatus,
KeyMode,
ModelCostUnit,
ModelProvider,
ModelStatus,
ModelType,
RegionStatus,
} from "@portal/api/infrastructure";
@@ -62,6 +68,31 @@ export const CERT_LABEL: Record<CertStatus, string> = {
"not-started": "Not started",
};
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
managed: "Stirling-managed",
byok: "BYOK",
hyok: "HYOK",
};
export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
managed: "info",
byok: "purple",
// HYOK is the strongest posture (Stirling never sees plaintext) → success.
hyok: "success",
};
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
attested: "Attested",
"in-scope": "In scope",
"not-applicable": "N/A",
};
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
attested: "success",
"in-scope": "warning",
"not-applicable": "neutral",
};
export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
success: "success",
warning: "warning",
@@ -84,3 +115,43 @@ export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
processing: "success",
security: "warning",
};
export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
active: "success",
degraded: "warning",
disabled: "neutral",
};
export const MODEL_LABEL: Record<ModelStatus, string> = {
active: "Active",
degraded: "Degraded",
disabled: "Disabled",
};
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
extraction: "Extraction",
classification: "Classification",
ocr: "OCR",
llm: "LLM",
};
export const MODEL_TYPE_TONE: Record<ModelType, ChipTone> = {
extraction: "blue",
classification: "purple",
ocr: "green",
llm: "amber",
};
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
stirling: "Stirling",
openai: "OpenAI",
anthropic: "Anthropic",
"on-prem": "On-prem",
};
/** Render a model's cost with the unit it's billed against. */
export function modelCost(cost: number, unit: ModelCostUnit): string {
if (cost === 0) return "Included";
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
return unit === "per-call" ? `${price}/call` : `${price}/1k`;
}
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DeployedPipelinesTable } from "@portal/components/pipelines/DeployedPipelinesTable";
import {
DEGRADED_PIPELINE,
HEALTHY_PIPELINE,
} from "@portal/components/pipelines/storyFixtures";
import "@portal/views/Pipelines.css";
const meta: Meta<typeof DeployedPipelinesTable> = {
title: "Portal/Pipelines/DeployedPipelinesTable",
component: DeployedPipelinesTable,
parameters: { layout: "padded" },
args: { onRowClick: () => {} },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DeployedPipelinesTable>;
/** A healthy pipeline at bound and one degraded below its golden-set bound. */
export const Default: Story = {
args: { pipelines: [HEALTHY_PIPELINE, DEGRADED_PIPELINE] },
};
export const Empty: Story = {
args: { pipelines: [] },
};
@@ -0,0 +1,100 @@
import { useMemo } from "react";
import { StatusBadge, Table, type TableColumn } from "@shared/components";
import type { Pipeline } from "@portal/api/pipelines";
import { compact, goldenTone, pct } from "@portal/components/pipelines/format";
interface DeployedPipelinesTableProps {
pipelines: Pipeline[];
onRowClick: (p: Pipeline) => void;
}
/**
* Dense roster of the deployed fleet that puts golden-set reliability up front.
* The card list below it carries the full per-pipeline story; this table is the
* scannable "is anything below its bound?" view across the whole fleet.
*/
export function DeployedPipelinesTable({
pipelines,
onRowClick,
}: DeployedPipelinesTableProps) {
const columns = useMemo<TableColumn<Pipeline>[]>(
() => [
{
key: "name",
header: "Pipeline",
render: (p) => (
<div className="portal-pipelines__roster-name">
<strong>{p.name}</strong>
<span className="portal-pipelines__roster-route">
{p.source} {p.destination}
</span>
</div>
),
},
{
key: "status",
header: "Health",
render: (p) => (
<StatusBadge
tone={p.status === "degraded" ? "warning" : "success"}
size="sm"
pulse={p.status === "degraded"}
>
{p.status === "degraded" ? "Degraded" : "Healthy"}
</StatusBadge>
),
},
{
key: "golden",
header: "Golden set",
width: "11rem",
render: (p) => {
const tone = goldenTone(p.golden);
const rate = p.golden.total ? p.golden.passing / p.golden.total : 0;
return (
<div className="portal-pipelines__roster-golden">
<StatusBadge tone={tone} size="sm">
{p.golden.passing}/{p.golden.total}
</StatusBadge>
<span
className="portal-pipelines__roster-rate"
title={`Bound: ${pct(p.golden.threshold, 0)}`}
>
{pct(rate, 1)}
</span>
</div>
);
},
},
{
key: "docs",
header: "Docs / 24h",
align: "right",
render: (p) => (
<span className="portal-pipelines__roster-num">
{compact(p.metrics.docs24h)}
</span>
),
},
{
key: "version",
header: "Version",
align: "right",
render: (p) => (
<span className="portal-pipelines__roster-version">{p.version}</span>
),
},
],
[],
);
return (
<Table<Pipeline>
className="portal-pipelines__roster"
columns={columns}
rows={pipelines}
rowKey={(p) => p.id}
onRowClick={onRowClick}
/>
);
}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PromotedPipelines } from "@portal/components/pipelines/PromotedPipelines";
import { PROMOTED_PIPELINES } from "@portal/components/pipelines/storyFixtures";
import "@portal/views/Pipelines.css";
const meta: Meta<typeof PromotedPipelines> = {
title: "Portal/Pipelines/PromotedPipelines",
component: PromotedPipelines,
parameters: { layout: "padded" },
args: { promoted: PROMOTED_PIPELINES },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PromotedPipelines>;
export const Default: Story = {};
export const Empty: Story = {
args: { promoted: [] },
};
@@ -0,0 +1,136 @@
import { useMemo, useState } from "react";
import {
Button,
StatusBadge,
type StatusTone,
Table,
type TableColumn,
} from "@shared/components";
import {
promoteToPolicy,
type PromotedPipeline,
type PromotedStatus,
} from "@portal/api/pipelines";
const STATUS_TONE: Record<PromotedStatus, StatusTone> = {
deployed: "success",
staged: "info",
review: "warning",
};
const STATUS_LABEL: Record<PromotedStatus, string> = {
deployed: "Deployed",
staged: "Staged",
review: "Needs review",
};
/** Per-row promote-to-policy lifecycle, kept local until a backend exists. */
type PromoteState = "idle" | "pending" | "done";
interface PromotedPipelinesProps {
promoted: PromotedPipeline[];
}
/**
* Flows that started as Editor watch-folder automations and were promoted into
* the portal. Each keeps a pointer back to the watch folder it grew from, and
* offers a one-click path to lift its rules into a fleet-wide org policy.
*/
export function PromotedPipelines({ promoted }: PromotedPipelinesProps) {
// Promote submits have no backend yet, so reflect acceptance per row locally.
const [promoteState, setPromoteState] = useState<
Record<string, PromoteState>
>({});
const onPromote = async (p: PromotedPipeline) => {
setPromoteState((s) => ({ ...s, [p.id]: "pending" }));
try {
// TODO(backend): POST /v1/pipelines/{id}/promote-to-policy — stubbed,
// resolves against the mock handler; treat success as accepted.
await promoteToPolicy(p.id);
setPromoteState((s) => ({ ...s, [p.id]: "done" }));
} catch {
setPromoteState((s) => ({ ...s, [p.id]: "idle" }));
}
};
const columns = useMemo<TableColumn<PromotedPipeline>[]>(
() => [
{
key: "name",
header: "Pipeline",
render: (p) => (
<div className="portal-pipelines__promoted-name">
<strong>{p.name}</strong>
<span className="portal-pipelines__promoted-when">
{p.promotedAt}
</span>
</div>
),
},
{
key: "docType",
header: "Source doc type",
render: (p) => (
<span className="portal-pipelines__promoted-muted">
{p.sourceDocType}
</span>
),
},
{
key: "watchFolder",
header: "Watch folder",
render: (p) => (
<code className="portal-pipelines__promoted-folder">
{p.watchFolder}
</code>
),
},
{
key: "status",
header: "Status",
render: (p) => (
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
{STATUS_LABEL[p.status]}
</StatusBadge>
),
},
{
key: "promote",
header: "",
align: "right",
width: "11rem",
render: (p) => {
const state = promoteState[p.id] ?? "idle";
if (state === "done") {
return (
<StatusBadge tone="success" size="sm">
Policy created
</StatusBadge>
);
}
return (
<Button
variant="outline"
size="sm"
loading={state === "pending"}
onClick={() => onPromote(p)}
>
Promote to policy
</Button>
);
},
},
],
[promoteState],
);
return (
<Table<PromotedPipeline>
className="portal-pipelines__promoted"
columns={columns}
rows={promoted}
rowKey={(p) => p.id}
/>
);
}
@@ -1,3 +1,6 @@
import type { GoldenSet } from "@portal/api/pipelines";
import type { StatusTone } from "@shared/components";
/** Fraction → percentage string (0.004 → "0.40%"). */
export const pct = (n: number, digits = 1) => `${(n * 100).toFixed(digits)}%`;
@@ -7,3 +10,17 @@ export const compact = (n: number) =>
notation: "compact",
maximumFractionDigits: 1,
}).format(n);
/**
* Golden-set reliability tone, judged against the pipeline's own pass-rate
* bound. At/above bound is green; a small slip under is amber; a clear miss is
* danger — so a row's reliability reads from colour alone.
*/
export function goldenTone(golden: GoldenSet): StatusTone {
if (golden.total === 0) return "neutral";
const rate = golden.passing / golden.total;
if (rate >= golden.threshold) return "success";
// Within five points of the bound is a warning; further off is a hard miss.
if (rate >= golden.threshold - 0.05) return "warning";
return "danger";
}
@@ -1,4 +1,4 @@
import type { Pipeline } from "@portal/api/pipelines";
import type { Pipeline, PromotedPipeline } from "@portal/api/pipelines";
/** Sample pipelines shared by the Pipelines component stories. */
@@ -33,7 +33,7 @@ export const HEALTHY_PIPELINE: Pipeline = {
},
{ key: "route", label: "Route / Store", ops: ["Primary store", "Notify"] },
],
golden: { passing: 42, total: 42, lastRun: "1h ago" },
golden: { passing: 42, total: 42, lastRun: "1h ago", threshold: 0.95 },
drift: [],
};
@@ -54,7 +54,7 @@ export const DEGRADED_PIPELINE: Pipeline = {
p95LatencyMs: 740,
uptime: 0.9962,
},
golden: { passing: 24, total: 28, lastRun: "47m ago" },
golden: { passing: 24, total: 28, lastRun: "47m ago", threshold: 0.9 },
drift: [
{
field: "procedure_codes",
@@ -72,3 +72,31 @@ export const DEGRADED_PIPELINE: Pipeline = {
},
],
};
/** Sample watch-folder-promoted flows for the PromotedPipelines stories. */
export const PROMOTED_PIPELINES: PromotedPipeline[] = [
{
id: "pl-promo-statements",
name: "Bank Statement Normalizer",
sourceDocType: "Bank statement",
watchFolder: "~/StirlingWatch/statements-in",
status: "deployed",
promotedAt: "promoted 3d ago",
},
{
id: "pl-promo-receipts",
name: "Receipt Splitter",
sourceDocType: "Expense receipt",
watchFolder: "~/StirlingWatch/receipts",
status: "staged",
promotedAt: "promoted 11h ago",
},
{
id: "pl-promo-onboarding",
name: "New-Hire Packet Sorter",
sourceDocType: "Onboarding packet",
watchFolder: "\\\\hr-share\\NewHireScans",
status: "review",
promotedAt: "promoted 2h ago",
},
];
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { PoliciesResponse } from "@portal/api/policies";
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
const RESPONSE: PoliciesResponse = {
summary: { active: 1, paused: 0, categories: 5, docsEnforced: 4821 },
catalogue: [],
};
const meta: Meta<typeof CatalogueSummary> = {
title: "Portal/Policies/CatalogueSummary",
component: CatalogueSummary,
parameters: { layout: "padded" },
args: { data: RESPONSE, loading: false },
};
export default meta;
type Story = StoryObj<typeof CatalogueSummary>;
export const Default: Story = {};
/** No data yet — every tile shows the em-dash placeholder. */
export const Loading: Story = { args: { data: null, loading: true } };
@@ -0,0 +1,40 @@
import { MetricCard, MetricStrip } from "@shared/components";
import type { PoliciesResponse } from "@portal/api/policies";
interface CatalogueSummaryProps {
data: PoliciesResponse | null;
loading: boolean;
}
/**
* Summary strip above the catalogue. Labels are product copy (they describe
* what each metric is, not its value) so the strip's structure stays stable
* across loading / ready states; only the values flow from the API.
*/
export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) {
const s = loading ? undefined : data?.summary;
return (
<MetricStrip>
<MetricCard
label="Active policies"
value={s ? s.active : "—"}
description="Enforcing on upload/export"
/>
<MetricCard
label="Paused"
value={s ? s.paused : "—"}
description="Configured but not firing"
/>
<MetricCard
label="Categories"
value={s ? s.categories : "—"}
description="Available to configure"
/>
<MetricCard
label="Docs enforced"
value={s ? s.docsEnforced.toLocaleString() : "—"}
description="Across active policies"
/>
</MetricStrip>
);
}
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
decorateForStory,
} from "@portal/components/policies/storyFixtures";
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const compliance = POLICY_CATEGORIES.find((c) => c.id === "compliance")!;
const meta: Meta<typeof PolicyCategoryCard> = {
title: "Portal/Policies/PolicyCategoryCard",
component: PolicyCategoryCard,
parameters: { layout: "padded" },
args: { onOpen: () => {} },
};
export default meta;
type Story = StoryObj<typeof PolicyCategoryCard>;
/** A configured, active policy — shows live stats. */
export const Configured: Story = {
args: {
entry: {
category: security,
config: POLICY_CONFIG.security,
policy: decorateForStory("security"),
},
},
};
/** Not yet set up — shows the rule chips + "Set up" affordance. */
export const NotSetUp: Story = {
args: {
entry: { category: security, config: POLICY_CONFIG.security, policy: null },
},
};
/** Coming-soon category — locked and inert. */
export const ComingSoon: Story = {
args: {
entry: {
category: compliance,
config: POLICY_CONFIG.compliance,
policy: null,
},
},
};

Some files were not shown because too many files have changed in this diff Show More