From 2b05865a84368e5b0d6c5e830b5ad76949c3d1e5 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:28:03 +0100 Subject: [PATCH] Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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): `. ## 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 --- frontend/portal/MOCKS.md | 14 +- frontend/portal/index.html | 5 +- frontend/portal/public/favicon.ico | Bin 0 -> 15406 bytes frontend/portal/src/ViewRouter.tsx | 10 + frontend/portal/src/api/agents.ts | 22 + frontend/portal/src/api/documents.ts | 26 + frontend/portal/src/api/editorDeploy.ts | 35 + frontend/portal/src/api/endpoints.ts | 15 - frontend/portal/src/api/home.ts | 3 + frontend/portal/src/api/infrastructure.ts | 18 + frontend/portal/src/api/pipelines.ts | 17 + frontend/portal/src/api/policies.ts | 90 +++ frontend/portal/src/api/sdkComponents.ts | 28 + frontend/portal/src/api/settings.ts | 3 + frontend/portal/src/api/users.ts | 24 + .../src/components/DocumentTypeGrid.css | 227 ------- .../components/DocumentTypeGrid.stories.tsx | 41 -- .../src/components/DocumentTypeGrid.tsx | 181 ------ .../src/components/PipelineForkWizard.css | 225 +++++++ .../components/PipelineForkWizard.stories.tsx | 20 + .../src/components/PipelineForkWizard.tsx | 158 +++++ .../portal/src/components/PolicySummary.css | 76 +++ .../src/components/PolicySummary.stories.tsx | 48 ++ .../portal/src/components/PolicySummary.tsx | 172 +++++ .../src/components/ProcessingStatusStrip.css | 78 +++ .../ProcessingStatusStrip.stories.tsx | 36 ++ .../src/components/ProcessingStatusStrip.tsx | 112 ++++ .../portal/src/components/SettingsModal.css | 26 +- .../portal/src/components/SettingsModal.tsx | 488 ++++++++++++-- frontend/portal/src/components/Sidebar.css | 14 +- frontend/portal/src/components/Sidebar.tsx | 27 +- .../AgentBuilderPanel.stories.tsx | 30 + .../agent-builder/AgentBuilderPanel.tsx | 72 +++ .../agent-builder/AgentKpiStrip.stories.tsx | 23 + .../agent-builder/AgentKpiStrip.tsx | 52 ++ .../agent-builder/AgentSelector.stories.tsx | 33 + .../agent-builder/AgentSelector.tsx | 44 ++ .../agent-builder/BootstrapDialog.stories.tsx | 30 + .../agent-builder/BootstrapDialog.tsx | 70 ++ .../agent-builder/EvalsPanel.stories.tsx | 36 ++ .../components/agent-builder/EvalsPanel.tsx | 100 +++ .../agent-builder/ScenariosPanel.stories.tsx | 30 + .../agent-builder/ScenariosPanel.tsx | 108 ++++ .../agent-builder/ToolsPanel.stories.tsx | 35 + .../components/agent-builder/ToolsPanel.tsx | 81 +++ .../agent-builder/VersionsPanel.stories.tsx | 35 + .../agent-builder/VersionsPanel.tsx | 99 +++ .../catalogue/ComponentCard.stories.tsx | 27 + .../components/catalogue/ComponentCard.tsx | 72 +++ .../ComponentDetailModal.stories.tsx | 27 + .../catalogue/ComponentDetailModal.tsx | 203 ++++++ .../catalogue/ComponentGrid.stories.tsx | 24 + .../components/catalogue/ComponentGrid.tsx | 30 + .../catalogue/ComponentPropsTable.stories.tsx | 16 + .../catalogue/ComponentPropsTable.tsx | 58 ++ .../catalogue/ComponentsSummaryStrip.tsx | 40 ++ .../documents/DocumentAudit.stories.tsx | 29 + .../components/documents/DocumentAudit.tsx | 36 ++ .../documents/DocumentDrawer.stories.tsx | 31 + .../components/documents/DocumentDrawer.tsx | 112 ++++ .../documents/DocumentExtractions.stories.tsx | 45 ++ .../documents/DocumentExtractions.tsx | 73 +++ .../documents/DocumentOverview.stories.tsx | 24 + .../components/documents/DocumentOverview.tsx | 22 + .../DocumentsSummaryStrip.stories.tsx | 26 + .../documents/DocumentsSummaryStrip.tsx | 46 ++ .../documents/ElevationBanner.stories.tsx | 37 ++ .../components/documents/ElevationBanner.tsx | 55 ++ .../documents/ReviewQueue.stories.tsx | 55 ++ .../src/components/documents/ReviewQueue.tsx | 115 ++++ .../documents/ReviewQueueTable.stories.tsx | 37 ++ .../components/documents/ReviewQueueTable.tsx | 112 ++++ .../portal/src/components/documents/format.ts | 28 + .../CredentialRotationCard.stories.tsx | 37 ++ .../editor-admin/CredentialRotationCard.tsx | 66 ++ .../DeploymentSummaryStrip.stories.tsx | 24 + .../editor-admin/DeploymentSummaryStrip.tsx | 35 + .../DeploymentTargets.stories.tsx | 48 ++ .../editor-admin/DeploymentTargets.tsx | 102 +++ .../InstanceHealthTable.stories.tsx | 26 + .../editor-admin/InstanceHealthTable.tsx | 92 +++ .../OfflineActivationCard.stories.tsx | 28 + .../editor-admin/OfflineActivationCard.tsx | 83 +++ .../editor-admin/PairingPanel.stories.tsx | 22 + .../components/editor-admin/PairingPanel.tsx | 101 +++ frontend/portal/src/components/icons.tsx | 43 ++ .../infrastructure/ModelsTab.stories.tsx | 30 + .../components/infrastructure/ModelsTab.tsx | 244 +++++++ .../infrastructure/SecurityTab.stories.tsx | 12 + .../components/infrastructure/SecurityTab.tsx | 113 ++++ .../components/infrastructure/infraFormat.ts | 73 ++- .../DeployedPipelinesTable.stories.tsx | 32 + .../pipelines/DeployedPipelinesTable.tsx | 100 +++ .../pipelines/PromotedPipelines.stories.tsx | 26 + .../pipelines/PromotedPipelines.tsx | 136 ++++ .../portal/src/components/pipelines/format.ts | 17 + .../src/components/pipelines/storyFixtures.ts | 34 +- .../policies/CatalogueSummary.stories.tsx | 22 + .../components/policies/CatalogueSummary.tsx | 40 ++ .../policies/PolicyCategoryCard.stories.tsx | 48 ++ .../policies/PolicyCategoryCard.tsx | 100 +++ .../policies/PolicyDetailPanel.stories.tsx | 45 ++ .../components/policies/PolicyDetailPanel.tsx | 206 ++++++ .../policies/PolicyFieldRow.stories.tsx | 72 +++ .../components/policies/PolicyFieldRow.tsx | 88 +++ .../policies/PolicySetupWizard.stories.tsx | 39 ++ .../components/policies/PolicySetupWizard.tsx | 461 +++++++++++++ .../src/components/policies/policyIcons.ts | 25 + .../src/components/policies/storyFixtures.ts | 40 ++ .../users/AccessControls.stories.tsx | 26 + .../src/components/users/AccessControls.tsx | 171 +++++ .../users/InviteMemberModal.stories.tsx | 16 + .../components/users/InviteMemberModal.tsx | 94 +++ .../components/users/MembersTable.stories.tsx | 29 + .../src/components/users/MembersTable.tsx | 142 ++++ .../components/users/RolesGrid.stories.tsx | 15 + .../portal/src/components/users/RolesGrid.tsx | 39 ++ .../users/UsersSummaryStrip.stories.tsx | 30 + .../components/users/UsersSummaryStrip.tsx | 35 + .../portal/src/components/users/format.ts | 16 + frontend/portal/src/contexts/ViewContext.tsx | 20 +- frontend/portal/src/mocks/agents.ts | 510 +++++++++++++++ frontend/portal/src/mocks/documents.ts | 559 ++++++++++++++++ frontend/portal/src/mocks/editorDeploy.ts | 393 +++++++++++ frontend/portal/src/mocks/handlers/agents.ts | 12 + .../portal/src/mocks/handlers/documents.ts | 12 + .../portal/src/mocks/handlers/editorDeploy.ts | 12 + .../portal/src/mocks/handlers/endpoints.ts | 15 - frontend/portal/src/mocks/handlers/index.ts | 14 +- .../src/mocks/handlers/infrastructure.ts | 6 + .../portal/src/mocks/handlers/pipelines.ts | 8 + .../portal/src/mocks/handlers/policies.ts | 183 ++++++ .../src/mocks/handlers/sdkComponents.ts | 12 + frontend/portal/src/mocks/handlers/users.ts | 12 + frontend/portal/src/mocks/home.ts | 66 ++ frontend/portal/src/mocks/infrastructure.ts | 410 ++++++++++++ frontend/portal/src/mocks/pipelines.ts | 93 ++- frontend/portal/src/mocks/policies.ts | 612 ++++++++++++++++++ frontend/portal/src/mocks/sdkComponents.ts | 522 +++++++++++++++ frontend/portal/src/mocks/settings.ts | 108 ++++ frontend/portal/src/mocks/users.ts | 399 ++++++++++++ frontend/portal/src/views/AgentBuilder.css | 458 +++++++++++++ frontend/portal/src/views/AgentBuilder.tsx | 98 +++ frontend/portal/src/views/Components.css | 244 +++++++ frontend/portal/src/views/Components.tsx | 88 +++ frontend/portal/src/views/Documents.css | 222 ++++++- frontend/portal/src/views/Documents.tsx | 17 +- frontend/portal/src/views/EditorAdmin.css | 328 ++++++++++ frontend/portal/src/views/EditorAdmin.tsx | 109 ++++ frontend/portal/src/views/Home.tsx | 10 + frontend/portal/src/views/Infrastructure.css | 60 ++ frontend/portal/src/views/Infrastructure.tsx | 34 +- frontend/portal/src/views/Pipelines.css | 85 +++ frontend/portal/src/views/Pipelines.tsx | 32 + frontend/portal/src/views/Placeholder.css | 33 - .../portal/src/views/Placeholder.stories.tsx | 26 - frontend/portal/src/views/Placeholder.tsx | 27 - frontend/portal/src/views/Policies.css | 443 +++++++++++++ frontend/portal/src/views/Policies.tsx | 183 ++++++ frontend/portal/src/views/Sources.css | 6 + frontend/portal/src/views/Sources.tsx | 24 +- frontend/portal/src/views/Users.css | 255 ++++++++ frontend/portal/src/views/Users.tsx | 100 +++ frontend/shared/components/SettingsShell.css | 170 +++++ .../components/SettingsShell.stories.tsx | 76 +++ frontend/shared/components/SettingsShell.tsx | 137 ++++ frontend/shared/components/index.ts | 1 + 167 files changed, 14365 insertions(+), 699 deletions(-) create mode 100644 frontend/portal/public/favicon.ico create mode 100644 frontend/portal/src/api/agents.ts create mode 100644 frontend/portal/src/api/documents.ts create mode 100644 frontend/portal/src/api/editorDeploy.ts delete mode 100644 frontend/portal/src/api/endpoints.ts create mode 100644 frontend/portal/src/api/policies.ts create mode 100644 frontend/portal/src/api/sdkComponents.ts create mode 100644 frontend/portal/src/api/users.ts delete mode 100644 frontend/portal/src/components/DocumentTypeGrid.css delete mode 100644 frontend/portal/src/components/DocumentTypeGrid.stories.tsx delete mode 100644 frontend/portal/src/components/DocumentTypeGrid.tsx create mode 100644 frontend/portal/src/components/PipelineForkWizard.css create mode 100644 frontend/portal/src/components/PipelineForkWizard.stories.tsx create mode 100644 frontend/portal/src/components/PipelineForkWizard.tsx create mode 100644 frontend/portal/src/components/PolicySummary.css create mode 100644 frontend/portal/src/components/PolicySummary.stories.tsx create mode 100644 frontend/portal/src/components/PolicySummary.tsx create mode 100644 frontend/portal/src/components/ProcessingStatusStrip.css create mode 100644 frontend/portal/src/components/ProcessingStatusStrip.stories.tsx create mode 100644 frontend/portal/src/components/ProcessingStatusStrip.tsx create mode 100644 frontend/portal/src/components/agent-builder/AgentBuilderPanel.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/AgentBuilderPanel.tsx create mode 100644 frontend/portal/src/components/agent-builder/AgentKpiStrip.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/AgentKpiStrip.tsx create mode 100644 frontend/portal/src/components/agent-builder/AgentSelector.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/AgentSelector.tsx create mode 100644 frontend/portal/src/components/agent-builder/BootstrapDialog.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/BootstrapDialog.tsx create mode 100644 frontend/portal/src/components/agent-builder/EvalsPanel.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/EvalsPanel.tsx create mode 100644 frontend/portal/src/components/agent-builder/ScenariosPanel.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/ScenariosPanel.tsx create mode 100644 frontend/portal/src/components/agent-builder/ToolsPanel.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/ToolsPanel.tsx create mode 100644 frontend/portal/src/components/agent-builder/VersionsPanel.stories.tsx create mode 100644 frontend/portal/src/components/agent-builder/VersionsPanel.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentCard.stories.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentCard.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentDetailModal.stories.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentDetailModal.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentGrid.stories.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentGrid.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentPropsTable.stories.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentPropsTable.tsx create mode 100644 frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx create mode 100644 frontend/portal/src/components/documents/DocumentAudit.stories.tsx create mode 100644 frontend/portal/src/components/documents/DocumentAudit.tsx create mode 100644 frontend/portal/src/components/documents/DocumentDrawer.stories.tsx create mode 100644 frontend/portal/src/components/documents/DocumentDrawer.tsx create mode 100644 frontend/portal/src/components/documents/DocumentExtractions.stories.tsx create mode 100644 frontend/portal/src/components/documents/DocumentExtractions.tsx create mode 100644 frontend/portal/src/components/documents/DocumentOverview.stories.tsx create mode 100644 frontend/portal/src/components/documents/DocumentOverview.tsx create mode 100644 frontend/portal/src/components/documents/DocumentsSummaryStrip.stories.tsx create mode 100644 frontend/portal/src/components/documents/DocumentsSummaryStrip.tsx create mode 100644 frontend/portal/src/components/documents/ElevationBanner.stories.tsx create mode 100644 frontend/portal/src/components/documents/ElevationBanner.tsx create mode 100644 frontend/portal/src/components/documents/ReviewQueue.stories.tsx create mode 100644 frontend/portal/src/components/documents/ReviewQueue.tsx create mode 100644 frontend/portal/src/components/documents/ReviewQueueTable.stories.tsx create mode 100644 frontend/portal/src/components/documents/ReviewQueueTable.tsx create mode 100644 frontend/portal/src/components/documents/format.ts create mode 100644 frontend/portal/src/components/editor-admin/CredentialRotationCard.stories.tsx create mode 100644 frontend/portal/src/components/editor-admin/CredentialRotationCard.tsx create mode 100644 frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.stories.tsx create mode 100644 frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.tsx create mode 100644 frontend/portal/src/components/editor-admin/DeploymentTargets.stories.tsx create mode 100644 frontend/portal/src/components/editor-admin/DeploymentTargets.tsx create mode 100644 frontend/portal/src/components/editor-admin/InstanceHealthTable.stories.tsx create mode 100644 frontend/portal/src/components/editor-admin/InstanceHealthTable.tsx create mode 100644 frontend/portal/src/components/editor-admin/OfflineActivationCard.stories.tsx create mode 100644 frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx create mode 100644 frontend/portal/src/components/editor-admin/PairingPanel.stories.tsx create mode 100644 frontend/portal/src/components/editor-admin/PairingPanel.tsx create mode 100644 frontend/portal/src/components/infrastructure/ModelsTab.stories.tsx create mode 100644 frontend/portal/src/components/infrastructure/ModelsTab.tsx create mode 100644 frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx create mode 100644 frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx create mode 100644 frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx create mode 100644 frontend/portal/src/components/pipelines/PromotedPipelines.tsx create mode 100644 frontend/portal/src/components/policies/CatalogueSummary.stories.tsx create mode 100644 frontend/portal/src/components/policies/CatalogueSummary.tsx create mode 100644 frontend/portal/src/components/policies/PolicyCategoryCard.stories.tsx create mode 100644 frontend/portal/src/components/policies/PolicyCategoryCard.tsx create mode 100644 frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx create mode 100644 frontend/portal/src/components/policies/PolicyDetailPanel.tsx create mode 100644 frontend/portal/src/components/policies/PolicyFieldRow.stories.tsx create mode 100644 frontend/portal/src/components/policies/PolicyFieldRow.tsx create mode 100644 frontend/portal/src/components/policies/PolicySetupWizard.stories.tsx create mode 100644 frontend/portal/src/components/policies/PolicySetupWizard.tsx create mode 100644 frontend/portal/src/components/policies/policyIcons.ts create mode 100644 frontend/portal/src/components/policies/storyFixtures.ts create mode 100644 frontend/portal/src/components/users/AccessControls.stories.tsx create mode 100644 frontend/portal/src/components/users/AccessControls.tsx create mode 100644 frontend/portal/src/components/users/InviteMemberModal.stories.tsx create mode 100644 frontend/portal/src/components/users/InviteMemberModal.tsx create mode 100644 frontend/portal/src/components/users/MembersTable.stories.tsx create mode 100644 frontend/portal/src/components/users/MembersTable.tsx create mode 100644 frontend/portal/src/components/users/RolesGrid.stories.tsx create mode 100644 frontend/portal/src/components/users/RolesGrid.tsx create mode 100644 frontend/portal/src/components/users/UsersSummaryStrip.stories.tsx create mode 100644 frontend/portal/src/components/users/UsersSummaryStrip.tsx create mode 100644 frontend/portal/src/components/users/format.ts create mode 100644 frontend/portal/src/mocks/agents.ts create mode 100644 frontend/portal/src/mocks/documents.ts create mode 100644 frontend/portal/src/mocks/editorDeploy.ts create mode 100644 frontend/portal/src/mocks/handlers/agents.ts create mode 100644 frontend/portal/src/mocks/handlers/documents.ts create mode 100644 frontend/portal/src/mocks/handlers/editorDeploy.ts delete mode 100644 frontend/portal/src/mocks/handlers/endpoints.ts create mode 100644 frontend/portal/src/mocks/handlers/policies.ts create mode 100644 frontend/portal/src/mocks/handlers/sdkComponents.ts create mode 100644 frontend/portal/src/mocks/handlers/users.ts create mode 100644 frontend/portal/src/mocks/policies.ts create mode 100644 frontend/portal/src/mocks/sdkComponents.ts create mode 100644 frontend/portal/src/mocks/users.ts create mode 100644 frontend/portal/src/views/AgentBuilder.css create mode 100644 frontend/portal/src/views/AgentBuilder.tsx create mode 100644 frontend/portal/src/views/Components.css create mode 100644 frontend/portal/src/views/Components.tsx create mode 100644 frontend/portal/src/views/EditorAdmin.css create mode 100644 frontend/portal/src/views/EditorAdmin.tsx delete mode 100644 frontend/portal/src/views/Placeholder.css delete mode 100644 frontend/portal/src/views/Placeholder.stories.tsx delete mode 100644 frontend/portal/src/views/Placeholder.tsx create mode 100644 frontend/portal/src/views/Policies.css create mode 100644 frontend/portal/src/views/Policies.tsx create mode 100644 frontend/portal/src/views/Users.css create mode 100644 frontend/portal/src/views/Users.tsx create mode 100644 frontend/shared/components/SettingsShell.css create mode 100644 frontend/shared/components/SettingsShell.stories.tsx create mode 100644 frontend/shared/components/SettingsShell.tsx diff --git a/frontend/portal/MOCKS.md b/frontend/portal/MOCKS.md index fd3d832f9e..17d6c68d55 100644 --- a/frontend/portal/MOCKS.md +++ b/frontend/portal/MOCKS.md @@ -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/.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. diff --git a/frontend/portal/index.html b/frontend/portal/index.html index 8148b26d25..c07e06d083 100644 --- a/frontend/portal/index.html +++ b/frontend/portal/index.html @@ -4,8 +4,9 @@ - - Stirling — Developer Portal + + + Stirling Processor diff --git a/frontend/portal/public/favicon.ico b/frontend/portal/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6d6c8521c12d1770066f2d3778a514492be5f8f0 GIT binary patch literal 15406 zcmeHOYit!o6kei<{xbT*|NPOYzi5%-6RY?LQUQ%<6fvM^)r3H-4@A*mjE@4hrMxS& z6k4EA1X?NrLQ%p?XnCoKwADfd5hzHZQ2IEYZ?>~{?%tijxzxCaqiqDDlh+0(1S-=*#Ua<%{9dGw0u;0@aX91q`iCpuy}yar=NaH z{rgXMQ)pF@XEQb|d*#X~1w_65?36D#TR-+gx;kBcfxf6($(?Kqd+(j$C?hj-1ub8` zk6wPc)bYEQO!d3dIEQ@9ahTiWe4x@mKIU4y6DCwftc_|tfB9$|lkzb^o<4nnMvq<= zp{FVz7^Y0AqI>RfZYzHn!flWGs(fH*ZEd5EKKjPOV3!XJ`T08qT|DyaG_Y+RR(V#)*ny$)l%lnlN40!76BlmH33`>{prRSe7_T3k;$_IwO{%WSRYk$`E zM_Ji^8aHm0uW^aFANw)1Z!Ax9bE}xQ?fT0yFvv52Tl>pz@4ma1T3W8e0)hACjjnAl zV1RiBkuto}5hF@?k9^)YhM?sG-=mMtqy77j`@)b6`M_}3U3pYd@slTpnwntSU+o`W zs|j;TdwYi#$Mosn)6}U|j$;7hFV_B7<;y(msJZ#FBZkjE-z4?}@4Z(Ug&`>Uz=3t{ z=+U!L7$9Hb95bd=?2}ac3qpU%|DlKG(5_t#5g1hYsQ2WPg|v9_9?=$h{iF9lQifNW zmX=GUrTch)V8XD=2R(4knX{eG`cD0ColC5C^O9-*j&b?fXPfBCm3DHHkNU75_z3Lt zANHTv1KWAVQ^vk%!i2S2Jv94zaUNrNJ#hZW5$6w_!+P@kCC`Dt&`syCsPm`9AkSk? z=aP8FM3}}L=NN0c^S8wCUrXU9VHd42ieJBe60Rzq^z_2(`}CQ9OKR%0OzyJ^Ztq4; z+rsT0dA17r_byqGS;6nCz%po1-gOe6KBZo%sX4dvJB2aT^Ba$!SUcCmsVDoLLv{RS zr=(21!!W3q4nLo5`fZYb#M@Jv^`vVWm5bJ#~6(nvs|2W{K&)k7;y;15xnA1e)##Q!}F3gzelwv_I68_)KGQx zG0MnT5cS>8ANY%ls|miMQ0TkB{APAJYMIBqSew+}WFGi|1y4gm6TR}va^Yu_-|eZy zFV7W-TRri_0`@KVAA@$DKFZp4RmBe-a4!1z;|*H>mmNR!C@A=WUV3R6rKLH?@OqNX#rAcO9haWn?*RXBdZ}j4eODx}+8-8gM@X2E?;C_$LJsEnq;fD@*8XC^> z*jO#%I)3outUPbt4sm`@24wGbP%C>e(;d>o5YN<9hui}U| z?-xIGfIoBf>Vx8S!>xXUU1w#LYvW<@;+Ax=;D-*diN?lrR9UI_H>>!OhmYoqFE+Cd zvH4#mMz=Ip{LleUQ`1G-xbZN3^;P%|h8?%PJZsiAjtR~WbghdQKXiZ}5j3i@gCUqh7E_KeAjmDvJ4$Q`D7#S-6Pj{wSB$vLE$(1 zp_9&^zf4=U)cV#z+QhJ71#zv_SmWV)t*t$6`JsU&kq7sqcJ6G5X>NM|{jarqUavlY z4$nTjm^N)X!uz)NM4(%=n48wEJLJ0`62UL^8#r(-VaVSR!kt5D`FTM2XY5dYAu;0fS2J37r z&((q-dLV{ZRJ4aKUTpQP0_5_T()k~2xXD*63 zFZ>ef9owG5FZFrjjg|EK?@hecx!(@~eypj8J7T{HpPddneouD$OZ>otJ9WEvA9d{l zi63-WABPStV1L~f5l42?qr31+Jg{r{S9JGuRQyu+^z_*@dGZF~XR+(ho%kgl*hFFB z55nJN?+hpWpu=5ntSwmQRUN$Wb02Dba1u{EDRjUd_|HEh?+NMfLk8|K!q2MOgd6@Y z?2Yl@^FxF6di3bhi2pNi!w;UYqoU&_J% zALQk2CEVFTEJFUSrriDmnwc v^m)n0cmjrc*r+*X!uJO3=w%spA!Ea=M-ShhnU*Q%8Hw-z^#2wJss;W5j>QEm literal 0 HcmV?d00001 diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx index 3051f74362..2efaabcc68 100644 --- a/frontend/portal/src/ViewRouter.tsx +++ b/frontend/portal/src/ViewRouter.tsx @@ -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 ( } /> + } /> } /> } /> + } /> + } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/portal/src/api/agents.ts b/frontend/portal/src/api/agents.ts new file mode 100644 index 0000000000..842d5d398b --- /dev/null +++ b/frontend/portal/src/api/agents.ts @@ -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 { + return httpJson( + `/v1/agents?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/documents.ts b/frontend/portal/src/api/documents.ts new file mode 100644 index 0000000000..c64fd1227b --- /dev/null +++ b/frontend/portal/src/api/documents.ts @@ -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 { + return httpJson( + `/v1/documents?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/editorDeploy.ts b/frontend/portal/src/api/editorDeploy.ts new file mode 100644 index 0000000000..f565cb2955 --- /dev/null +++ b/frontend/portal/src/api/editorDeploy.ts @@ -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 { + return httpJson( + `/v1/editor/deployment?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/endpoints.ts b/frontend/portal/src/api/endpoints.ts deleted file mode 100644 index 95bab777be..0000000000 --- a/frontend/portal/src/api/endpoints.ts +++ /dev/null @@ -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 { - return httpJson("/v1/endpoints"); -} diff --git a/frontend/portal/src/api/home.ts b/frontend/portal/src/api/home.ts index b7b8fee648..122e1ca644 100644 --- a/frontend/portal/src/api/home.ts +++ b/frontend/portal/src/api/home.ts @@ -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 { diff --git a/frontend/portal/src/api/infrastructure.ts b/frontend/portal/src/api/infrastructure.ts index 0154ae6a40..92255aece2 100644 --- a/frontend/portal/src/api/infrastructure.ts +++ b/frontend/portal/src/api/infrastructure.ts @@ -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 { return httpJson(`/v1/infrastructure/security${q(tier)}`); } +/** GET /v1/infrastructure/models?tier=… */ +export async function fetchModels(tier: Tier): Promise { + return httpJson(`/v1/infrastructure/models${q(tier)}`); +} + /** GET /v1/infrastructure/storage?tier=… */ export async function fetchStorage(tier: Tier): Promise { return httpJson(`/v1/infrastructure/storage${q(tier)}`); diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts index d3a250f31e..e9c3ae2f2e 100644 --- a/frontend/portal/src/api/pipelines.ts +++ b/frontend/portal/src/api/pipelines.ts @@ -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 { `/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" }, + ); +} diff --git a/frontend/portal/src/api/policies.ts b/frontend/portal/src/api/policies.ts new file mode 100644 index 0000000000..c17a5f0b11 --- /dev/null +++ b/frontend/portal/src/api/policies.ts @@ -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 { + return httpJson("/api/v1/policies"); +} + +/** GET /api/v1/policies/{id} — one stored policy's raw record. */ +export async function fetchPolicy(id: string): Promise { + return httpJson(`/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 { + return httpJson("/api/v1/policies", { method: "POST", body: policy }); +} + +/** DELETE /api/v1/policies/{id} — remove a stored policy. */ +export async function deletePolicy(id: string): Promise { + await httpJson(`/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 { + return httpJson( + `/api/v1/policies/${encodeURIComponent(id)}/run`, + { method: "POST" }, + ); +} diff --git a/frontend/portal/src/api/sdkComponents.ts b/frontend/portal/src/api/sdkComponents.ts new file mode 100644 index 0000000000..ed02afd9a1 --- /dev/null +++ b/frontend/portal/src/api/sdkComponents.ts @@ -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 { + return httpJson( + `/v1/components?tier=${encodeURIComponent(tier)}`, + ); +} diff --git a/frontend/portal/src/api/settings.ts b/frontend/portal/src/api/settings.ts index e6e850359a..8cba5d0cc1 100644 --- a/frontend/portal/src/api/settings.ts +++ b/frontend/portal/src/api/settings.ts @@ -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"; diff --git a/frontend/portal/src/api/users.ts b/frontend/portal/src/api/users.ts new file mode 100644 index 0000000000..f9e7b11c08 --- /dev/null +++ b/frontend/portal/src/api/users.ts @@ -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 { + return httpJson(`/v1/users?tier=${encodeURIComponent(tier)}`); +} diff --git a/frontend/portal/src/components/DocumentTypeGrid.css b/frontend/portal/src/components/DocumentTypeGrid.css deleted file mode 100644 index 9d97efd0c5..0000000000 --- a/frontend/portal/src/components/DocumentTypeGrid.css +++ /dev/null @@ -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); -} diff --git a/frontend/portal/src/components/DocumentTypeGrid.stories.tsx b/frontend/portal/src/components/DocumentTypeGrid.stories.tsx deleted file mode 100644 index 56d0f33f3e..0000000000 --- a/frontend/portal/src/components/DocumentTypeGrid.stories.tsx +++ /dev/null @@ -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 = { - title: "Portal/Home/DocumentTypeGrid", - component: DocumentTypeGrid, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -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([]))], - }, - }, -}; diff --git a/frontend/portal/src/components/DocumentTypeGrid.tsx b/frontend/portal/src/components/DocumentTypeGrid.tsx deleted file mode 100644 index 8aec2784aa..0000000000 --- a/frontend/portal/src/components/DocumentTypeGrid.tsx +++ /dev/null @@ -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 ( - - {TIER_LABEL[tier]} - - ); -} - -function EndpointCard({ - endpoint, - vertical, -}: { - endpoint: Endpoint; - vertical: Vertical; -}) { - const visibleRegions = endpoint.regions.slice(0, 2); - const extraRegions = endpoint.regions.length - visibleRegions.length; - return ( -
-
-
-
- {vertical.label.toUpperCase()} -
-

{endpoint.name}

-

{endpoint.desc}

-
- - {visibleRegions.join(" · ")} - {extraRegions > 0 && ( - - {" "} - +{extraRegions} more - - )} - - {tierBadge(endpoint.tier)} -
- e.preventDefault()} - > - Explore - -
-
- ); -} - -function GridSkeleton() { - return ( -
- {Array.from({ length: 2 }).map((_, gi) => ( -
-
- - -
-
- {Array.from({ length: 4 }).map((_, ci) => ( - - ))} -
-
- ))} -
- ); -} - -export function DocumentTypeGrid() { - const [tab, setTab] = useState("all"); - const state = useAsync(() => fetchVerticals(), []); - const { data: verticals } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - const hasVerticals = verticals !== null && verticals.length > 0; - - const tabItems: TabItem[] = hasVerticals - ? [ - { key: "all", label: "All" }, - ...verticals.map>((v) => ({ - key: v.key, - label: v.label, - count: v.endpoints.length, - accentColor: v.color, - dotColor: v.color, - })), - ] - : []; - - return ( -
-
-

Document types

-

- Typed endpoints across every supported vertical — each carries a - schema, region availability and tier gate. -

-
- - {hasVerticals && ( - - items={tabItems} - activeKey={tab} - onChange={setTab} - ariaLabel="Document type verticals" - /> - )} - - {isLoading && } - - {isEmpty && ( - - )} - - {hasVerticals && ( -
- {(tab === "all" - ? verticals - : verticals.filter((v) => v.key === tab) - ).map((v) => ( -
- {tab === "all" && ( -
- -

{v.label}

- - {v.endpoints.length} endpoints - -
- )} -
- {v.endpoints.map((endpoint) => ( - - ))} -
-
- ))} -
- )} -
- ); -} diff --git a/frontend/portal/src/components/PipelineForkWizard.css b/frontend/portal/src/components/PipelineForkWizard.css new file mode 100644 index 0000000000..a126f0dc4f --- /dev/null +++ b/frontend/portal/src/components/PipelineForkWizard.css @@ -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; +} diff --git a/frontend/portal/src/components/PipelineForkWizard.stories.tsx b/frontend/portal/src/components/PipelineForkWizard.stories.tsx new file mode 100644 index 0000000000..8701e8e9c5 --- /dev/null +++ b/frontend/portal/src/components/PipelineForkWizard.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PipelineForkWizard } from "@portal/components/PipelineForkWizard"; + +const meta: Meta = { + title: "Portal/Home/PipelineForkWizard", + component: PipelineForkWizard, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Pick a template to watch the deterministic four-stage build animation. */ +export const Default: Story = {}; diff --git a/frontend/portal/src/components/PipelineForkWizard.tsx b/frontend/portal/src/components/PipelineForkWizard.tsx new file mode 100644 index 0000000000..cd9e54e268 --- /dev/null +++ b/frontend/portal/src/components/PipelineForkWizard.tsx @@ -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("pick"); + const [template, setTemplate] = useState(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(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 ( + +
+
+

Fork a starter pipeline

+

+ Clone a proven workflow and tune it — every template ships the same + four-stage backbone. +

+
+ {phase !== "pick" && template && ( + + {phase === "ready" ? "Ready to deploy" : "Building…"} + + )} +
+ + {phase === "pick" && ( +
+ {PIPELINE_TEMPLATES.map((t) => ( + + ))} +
+ )} + + {phase !== "pick" && template && ( +
+
+ {template.name} + {template.blurb} +
+ +
    + {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 ( +
  1. + + {done ? "✓" : i + 1} + + + {stage.label} + {stage.detail} + + {active && ( + + )} +
  2. + ); + })} +
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/PolicySummary.css b/frontend/portal/src/components/PolicySummary.css new file mode 100644 index 0000000000..9b4e475895 --- /dev/null +++ b/frontend/portal/src/components/PolicySummary.css @@ -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; +} diff --git a/frontend/portal/src/components/PolicySummary.stories.tsx b/frontend/portal/src/components/PolicySummary.stories.tsx new file mode 100644 index 0000000000..6de9f437ac --- /dev/null +++ b/frontend/portal/src/components/PolicySummary.stories.tsx @@ -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 = { + title: "Portal/Home/PolicySummary", + component: PolicySummary, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +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: [], + }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/PolicySummary.tsx b/frontend/portal/src/components/PolicySummary.tsx new file mode 100644 index 0000000000..37fe47deae --- /dev/null +++ b/frontend/portal/src/components/PolicySummary.tsx @@ -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(() => fetchPolicies(), []); + const { data } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + const goToPolicies = () => setActiveView("policies"); + + const columns: TableColumn[] = [ + { + key: "category", + header: "Policy", + render: ({ entry }) => ( +
+ + {policyIcon(entry.category.icon)} + +
+ {entry.category.label} + {entry.category.desc} +
+
+ ), + }, + { + key: "status", + header: "Status", + width: "7rem", + render: ({ state }) => { + const badge = STATE_BADGE[state]; + return ( + + {badge.label} + + ); + }, + }, + { + key: "rule", + header: "Active rule", + render: ({ entry, state }) => ( + + {state === "active" ? entry.config.summary : "No rule enforced yet"} + + ), + }, + { + key: "action", + header: "", + align: "right", + width: "9rem", + render: ({ state }) => { + if (state === "locked") { + return ( + + ); + } + return ( + + ); + }, + }, + ]; + + const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? []; + + return ( +
+ +
+
+

What runs on your PDFs

+

+ Standing automations every document passes through, regardless of + which pipeline handles it. +

+
+ {data && ( + + {data.summary.active} / {data.summary.categories} active + + )} +
+ + {isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+ )} + + {isEmpty && ( + + )} + + {data && rows.length > 0 && ( + r.entry.category.id} + onRowClick={goToPolicies} + /> + )} + + + ); +} diff --git a/frontend/portal/src/components/ProcessingStatusStrip.css b/frontend/portal/src/components/ProcessingStatusStrip.css new file mode 100644 index 0000000000..911bf99541 --- /dev/null +++ b/frontend/portal/src/components/ProcessingStatusStrip.css @@ -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; +} diff --git a/frontend/portal/src/components/ProcessingStatusStrip.stories.tsx b/frontend/portal/src/components/ProcessingStatusStrip.stories.tsx new file mode 100644 index 0000000000..c5df1139d5 --- /dev/null +++ b/frontend/portal/src/components/ProcessingStatusStrip.stories.tsx @@ -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 = { + title: "Portal/Home/ProcessingStatusStrip", + component: ProcessingStatusStrip, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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" }]); + }), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/ProcessingStatusStrip.tsx b/frontend/portal/src/components/ProcessingStatusStrip.tsx new file mode 100644 index 0000000000..4268eb3ee8 --- /dev/null +++ b/frontend/portal/src/components/ProcessingStatusStrip.tsx @@ -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( + () => fetchHomeKpis(tier), + [tier], + ); + + if (loading) { + return ( +
+ + +
+ ); + } + + 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 ( + setActiveView("usage")} + > + Upgrade + + ) : undefined + } + > +
+ + {used.toLocaleString()} / {cap.toLocaleString()}{" "} + PDFs this month + + + {Math.round(ratio * 100)}% + +
+ +
+ ); + } + + // Pro / enterprise: plan name + headline volume from the first KPI. + const volume = kpis?.[0]?.value; + return ( +
+ + + {TIER_INFO[tier].label} + + + · + + + {volume ?? "—"} PDFs processed · last 30 days + + +
+ ); +} diff --git a/frontend/portal/src/components/SettingsModal.css b/frontend/portal/src/components/SettingsModal.css index c43e0655a5..59fa1e0bc4 100644 --- a/frontend/portal/src/components/SettingsModal.css +++ b/frontend/portal/src/components/SettingsModal.css @@ -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; diff --git a/frontend/portal/src/components/SettingsModal.tsx b/frontend/portal/src/components/SettingsModal.tsx index 3d3450d144..a770e9f587 100644 --- a/frontend/portal/src/components/SettingsModal.tsx +++ b/frontend/portal/src/components/SettingsModal.tsx @@ -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[] = [ - { key: "profile", label: "Profile" }, - { key: "preferences", label: "Preferences" }, - { key: "workspace", label: "Workspace" }, +const SECTION_LABEL: Record = { + 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: }, + { key: "appearance", label: "Appearance", icon: }, + { + key: "notifications", + label: "Notifications", + icon: , + }, + ], + }, + { + title: "Workspace", + items: [ + { key: "general", label: "General", icon: }, + ], + }, + { + title: "Admin", + items: [ + { + key: "authentication", + label: "Authentication", + icon: , + }, + { + key: "sessions", + label: "Active sessions", + icon: , + }, + { + key: "early-access", + label: "Early access", + icon: , + }, + ], + }, ]; 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("profile"); + const [section, setSection] = useState("profile"); const { data: snapshot, loading } = useAsync( () => fetchSettings(tier), @@ -95,6 +176,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { const [notifications, setNotifications] = useState>( {}, ); + const [security, setSecurity] = useState({ + mfaEnforced: false, + ssoEnabled: false, + scimEnabled: false, + sessionTimeoutMins: 480, + }); + const [betaToggles, setBetaToggles] = useState>({}); 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(() => { @@ -129,36 +226,31 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { - - Changes apply to this workspace. - - - - - } > -
- -
- -
- {tab === "profile" && ( + setSection(k as SettingsSection)} + title={SECTION_LABEL[section]} + onClose={onClose} + footer={ + <> + + Changes apply to this workspace. + + + + + } + > + {section === "profile" && ( )} - {tab === "preferences" && ( - + )} + + {section === "notifications" && ( + n.id) ?? []} onToggle={(id, value) => setNotifications((prev) => ({ ...prev, [id]: value })) } - theme={theme} - onTheme={setTheme} /> )} - {tab === "workspace" && ( + {section === "general" && ( )} -
+ + {section === "authentication" && ( + setSecurity((s) => ({ ...s, ...patch }))} + /> + )} + + {section === "sessions" && ( + + )} + + {section === "early-access" && ( + + setBetaToggles((prev) => ({ ...prev, [id]: value })) + } + /> + )} +
); } @@ -286,21 +408,13 @@ function ProfilePanel({ } /* ──────────────────────────────────────────────────────────────────────── */ -/* Preferences */ +/* Appearance */ /* ──────────────────────────────────────────────────────────────────────── */ -function PreferencesPanel({ - loading, - notifications, - order, - onToggle, +function AppearancePanel({ theme, onTheme, }: { - loading: boolean; - notifications: Record; - order: string[]; - onToggle: (id: string, value: boolean) => void; theme: Theme; onTheme: (theme: Theme) => void; }) { @@ -308,7 +422,7 @@ function PreferencesPanel({
-

Appearance

+

Theme

Choose how the portal looks on this device.

@@ -345,10 +459,30 @@ function PreferencesPanel({ ))}
+
+ ); +} +/* ──────────────────────────────────────────────────────────────────────── */ +/* Notifications */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function NotificationsPanel({ + loading, + notifications, + order, + onToggle, +}: { + loading: boolean; + notifications: Record; + order: string[]; + onToggle: (id: string, value: boolean) => void; +}) { + return ( +
-

Notifications

+

Email notifications

Pick which events reach your inbox.

@@ -469,3 +603,233 @@ function WorkspacePanel({
); } + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Admin · Authentication */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function AuthenticationPanel({ + loading, + tier, + security, + onSecurity, +}: { + loading: boolean; + tier: Tier; + security: SecurityForm; + onSecurity: (patch: Partial) => void; +}) { + if (loading) { + return ( +
+ + + +
+ ); + } + + // 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 ( +
+
+
+

Sign-in policy

+

+ Organisation-wide authentication controls. +

+
+ +
+
+
+ Enforce two-factor (MFA) + Require every member to complete MFA at sign-in. +
+ onSecurity({ mfaEnforced: v })} + /> +
+ +
+
+ + Single sign-on (SAML) + {!isEnterprise && ( + + Enterprise + + )} + + Federate sign-in through your identity provider. +
+ onSecurity({ ssoEnabled: v })} + /> +
+ +
+
+ + SCIM provisioning + {!isEnterprise && ( + + Enterprise + + )} + + Sync members and roles from your directory. +
+ onSecurity({ scimEnabled: v })} + /> +
+
+ + + setFileName(e.target.files?.[0]?.name ?? null)} + /> + + ⇪ + + + {fileName ?? "Choose a sample document (PDF or image)"} + + +
+ + ); +} diff --git a/frontend/portal/src/components/agent-builder/EvalsPanel.stories.tsx b/frontend/portal/src/components/agent-builder/EvalsPanel.stories.tsx new file mode 100644 index 0000000000..39d1676dd1 --- /dev/null +++ b/frontend/portal/src/components/agent-builder/EvalsPanel.stories.tsx @@ -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 = { + title: "Portal/AgentBuilder/EvalsPanel", + component: EvalsPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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] }, +}; diff --git a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx new file mode 100644 index 0000000000..58c601accd --- /dev/null +++ b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx @@ -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[] = [ + { key: "name", header: "Eval case", render: (c) => c.name }, + { + key: "result", + header: "Result", + render: (c) => + c.passing === null ? ( + not run + ) : ( + + {c.passing ? "pass" : "fail"} + + ), + }, + { + key: "latency", + header: "Latency", + align: "right", + render: (c) => ( + {c.latencyMs} ms + ), + }, +]; + +/** Golden-set pass-rate, the per-case results table, and a run affordance. */ +export function EvalsPanel({ agent }: EvalsPanelProps) { + if (agent.evalsTotal === 0) { + return ( +
+ +
+ ); + } + + 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 ( +
+
+
+ = 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"} + /> + +
+ +
+ +
+
+ Golden-set pass rate + {Math.round(rate * 100)}% +
+ = 0.95 ? "var(--color-green)" : "var(--color-amber)"} + label="Golden-set pass rate" + /> +
+ + + columns={COLUMNS} + rows={agent.evalCases} + rowKey={(c) => c.id} + /> +
+ ); +} diff --git a/frontend/portal/src/components/agent-builder/ScenariosPanel.stories.tsx b/frontend/portal/src/components/agent-builder/ScenariosPanel.stories.tsx new file mode 100644 index 0000000000..8ae91e390a --- /dev/null +++ b/frontend/portal/src/components/agent-builder/ScenariosPanel.stories.tsx @@ -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 = { + title: "Portal/AgentBuilder/ScenariosPanel", + component: ScenariosPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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] }, +}; diff --git a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx new file mode 100644 index 0000000000..84ef11ecb2 --- /dev/null +++ b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx @@ -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(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 ( +
+
    + {scenarios.map((s) => ( +
  • +
    +
    + {s.name} + + {s.enabled ? "in eval" : "muted"} + +
    + + {s.expectation} + +
    + +
  • + ))} +
+ +
+ + Add scenario + +
+ + setName(e.target.value)} + placeholder="e.g. Compliance escalation" + /> + + + setExpectation(e.target.value)} + placeholder="What the agent should do" + /> + + +
+
+
+ ); +} diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.stories.tsx b/frontend/portal/src/components/agent-builder/ToolsPanel.stories.tsx new file mode 100644 index 0000000000..ed0de96ff9 --- /dev/null +++ b/frontend/portal/src/components/agent-builder/ToolsPanel.stories.tsx @@ -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 = { + title: "Portal/AgentBuilder/ToolsPanel", + component: ToolsPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx new file mode 100644 index 0000000000..c53faa2a60 --- /dev/null +++ b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx @@ -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(agent.toolMode); + const [denied, setDenied] = useState(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 ( +
+
+ + + {restricted ? "Restricted" : "Broad access"} + +
+ + {restricted && ( +
+ Denied tools +

+ Selected tools are blocked. Everything else stays callable. +

+
+ {TOOL_CATALOGUE.map((tool) => { + const isDenied = denied.includes(tool); + return ( + toggleDenied(tool) : undefined + } + > + {isDenied ? "✕ " : ""} + {tool} + + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.stories.tsx b/frontend/portal/src/components/agent-builder/VersionsPanel.stories.tsx new file mode 100644 index 0000000000..d15fdac6e0 --- /dev/null +++ b/frontend/portal/src/components/agent-builder/VersionsPanel.stories.tsx @@ -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 = { + title: "Portal/AgentBuilder/VersionsPanel", + component: VersionsPanel, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx new file mode 100644 index 0000000000..4a9bc5377a --- /dev/null +++ b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx @@ -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 ( +
+
    + {versions.map((v) => { + const isCurrent = v.version === agent.version; + return ( +
  1. +
    +
    + + {v.version} + + + {v.status} + + {isCurrent && ( + + current + + )} +
    + {v.note} + + {formatDate(v.createdAt)} · {v.author} + +
    +
    + {v.status === "draft" && ( + + )} + {v.status === "published" && !isCurrent && ( + + )} +
    +
  2. + ); + })} +
+ + {!historyUnlocked && publishedExists && ( +

+ Full version history and rollback are available on the Enterprise + plan. +

+ )} +
+ ); +} diff --git a/frontend/portal/src/components/catalogue/ComponentCard.stories.tsx b/frontend/portal/src/components/catalogue/ComponentCard.stories.tsx new file mode 100644 index 0000000000..0b6c284d93 --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentCard.stories.tsx @@ -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 = { + title: "Portal/Components/ComponentCard", + component: ComponentCard, + parameters: { layout: "padded" }, + args: { component: GA, unlocked: true, onOpen: () => {} }, +}; +export default meta; +type Story = StoryObj; + +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 }, +}; diff --git a/frontend/portal/src/components/catalogue/ComponentCard.tsx b/frontend/portal/src/components/catalogue/ComponentCard.tsx new file mode 100644 index 0000000000..6b2c46d8ab --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentCard.tsx @@ -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 ( + onOpen(component)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(component); + } + }} + > +
+

{component.name}

+ + {maturity.label} + + {!unlocked && ( + + 🔒 + + )} +
+ +

{component.description}

+ +
+ + {formatPrice(component.pricing)} + + + @stirling/{component.package} + +
+ +
+ {component.frameworks.map((fw) => ( + + {fw} + + ))} +
+
+ ); +} diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.stories.tsx b/frontend/portal/src/components/catalogue/ComponentDetailModal.stories.tsx new file mode 100644 index 0000000000..7a8be30dde --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentDetailModal.stories.tsx @@ -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 = { + title: "Portal/Components/ComponentDetailModal", + component: ComponentDetailModal, + parameters: { layout: "fullscreen" }, + args: { component: VIEWER, unlocked: true, onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +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 }, +}; diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx new file mode 100644 index 0000000000..d9f8627a38 --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx @@ -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("overview"); + + // Reset to the first tab whenever a new component is opened. + const open = component !== null; + if (!component) { + return ( + + ); + } + + const maturity = MATURITY_META[component.maturity]; + const npm = `@stirling/${component.package}`; + + return ( + { + onClose(); + setTab("overview"); + }} + width="xl" + title={ + + {component.name} + + {maturity.label} + + + } + subtitle={npm} + footer={ + unlocked ? ( +
+ + {formatPrice(component.pricing)} + + +
+ ) : ( + + ) + } + > + {!unlocked && ( + + )} + + {/* Live-preview sandbox — a styled placeholder until a real host mounts. */} +
+ {/* TODO(backend)/host: mount the live here, booting the + component against a demo document and the dev's publishable key. */} + Live preview + + Interactive sandbox renders here + +
+ + + className="portal-components__tabs" + items={TABS} + activeKey={tab} + onChange={setTab} + variant="underline" + ariaLabel="Component detail sections" + /> + +
+ {tab === "overview" && ( +
+

+ {component.description} +

+
+ {component.frameworks.map((fw) => ( + + {fw} + + ))} +
+
+ + + 0 + ? `${component.pricing.freeQuota.toLocaleString()} / mo` + : "None" + } + /> + +
+
+ )} + + {tab === "code" && ( +
+ + +
+ )} + + {tab === "props" && } + + {tab === "pricing" && ( +
+
+ + + 0 + ? `${component.pricing.freeQuota.toLocaleString()} / mo` + : "None" + } + /> +
+

+ Metered per {component.pricing.unit}. Usage beyond the monthly + free quota is billed to your account and itemised under Usage + & Billing. +

+
+ )} +
+
+ ); +} diff --git a/frontend/portal/src/components/catalogue/ComponentGrid.stories.tsx b/frontend/portal/src/components/catalogue/ComponentGrid.stories.tsx new file mode 100644 index 0000000000..b11c302b60 --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentGrid.stories.tsx @@ -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 = { + title: "Portal/Components/ComponentGrid", + component: ComponentGrid, + parameters: { layout: "padded" }, + args: { components: componentsFor("pro"), tier: "pro", onOpen: () => {} }, +}; +export default meta; +type Story = StoryObj; + +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" }, +}; diff --git a/frontend/portal/src/components/catalogue/ComponentGrid.tsx b/frontend/portal/src/components/catalogue/ComponentGrid.tsx new file mode 100644 index 0000000000..140f71c07f --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentGrid.tsx @@ -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 ( +
+ {components.map((c) => ( + + ))} +
+ ); +} diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.stories.tsx b/frontend/portal/src/components/catalogue/ComponentPropsTable.stories.tsx new file mode 100644 index 0000000000..7ac11e6593 --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentPropsTable.stories.tsx @@ -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 = { + title: "Portal/Components/ComponentPropsTable", + component: ComponentPropsTable, + parameters: { layout: "padded" }, + args: { props: VIEWER.props }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx new file mode 100644 index 0000000000..03f8e46925 --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx @@ -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[]>( + () => [ + { + key: "name", + header: "Prop", + render: (p) => ( + {p.name} + ), + }, + { + key: "type", + header: "Type", + render: (p) => ( + {p.type} + ), + }, + { + key: "required", + header: "Required", + render: (p) => + p.required ? ( + + required + + ) : ( + optional + ), + }, + { + key: "description", + header: "Description", + render: (p) => ( + {p.description} + ), + }, + ], + [], + ); + + return ( + + columns={columns} + rows={rows} + rowKey={(p) => p.name} + /> + ); +} diff --git a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx new file mode 100644 index 0000000000..8cad2c51ae --- /dev/null +++ b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx @@ -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 ( + + {KPI_LABELS.map((label, i) => ( + + ))} + + ); +} diff --git a/frontend/portal/src/components/documents/DocumentAudit.stories.tsx b/frontend/portal/src/components/documents/DocumentAudit.stories.tsx new file mode 100644 index 0000000000..204b1d1e61 --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentAudit.stories.tsx @@ -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 = { + title: "Portal/Documents/DocumentAudit", + component: DocumentAudit, + parameters: { layout: "padded" }, + args: { doc: DOC }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** A document whose lifecycle reached approval. */ +export const Approved: Story = { + args: { doc: documentsFor("pro").find((d) => d.status === "processed")! }, +}; diff --git a/frontend/portal/src/components/documents/DocumentAudit.tsx b/frontend/portal/src/components/documents/DocumentAudit.tsx new file mode 100644 index 0000000000..1d5949ec2b --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentAudit.tsx @@ -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

No events recorded yet.

; + } + return ( +
    + {doc.audit.map((event) => ( +
  1. + +
    +
    + + {DOC_AUDIT_LABEL[event.kind]} + + + {event.time} + +
    +

    {event.detail}

    + + {event.actor} + +
    +
  2. + ))} +
+ ); +} diff --git a/frontend/portal/src/components/documents/DocumentDrawer.stories.tsx b/frontend/portal/src/components/documents/DocumentDrawer.stories.tsx new file mode 100644 index 0000000000..41d5382dea --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentDrawer.stories.tsx @@ -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 = { + title: "Portal/Documents/DocumentDrawer", + component: DocumentDrawer, + parameters: { layout: "fullscreen" }, + args: { onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/documents/DocumentDrawer.tsx b/frontend/portal/src/components/documents/DocumentDrawer.tsx new file mode 100644 index 0000000000..195b97c258 --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentDrawer.tsx @@ -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[] = [ + { 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("overview"); + // Seconds remaining on the active elevation grant; null means no grant. + const [secondsLeft, setSecondsLeft] = useState(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 ( + +
+
+ + {DOCUMENT_STATUS_LABEL[doc.status]} + +
+ + {doc.sensitive && ( + + )} + + + items={SUB_TABS} + activeKey={tab} + onChange={setTab} + variant="underline" + ariaLabel="Document detail sections" + /> + +
+ {tab === "overview" && } + {tab === "extractions" && ( + + )} + {tab === "audit" && } +
+
+
+ ); +} diff --git a/frontend/portal/src/components/documents/DocumentExtractions.stories.tsx b/frontend/portal/src/components/documents/DocumentExtractions.stories.tsx new file mode 100644 index 0000000000..a729289915 --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentExtractions.stories.tsx @@ -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 = { + title: "Portal/Documents/DocumentExtractions", + component: DocumentExtractions, + parameters: { layout: "padded" }, + args: { doc: NON_SENSITIVE, unlocked: false }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/documents/DocumentExtractions.tsx b/frontend/portal/src/components/documents/DocumentExtractions.tsx new file mode 100644 index 0000000000..a990113af9 --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentExtractions.tsx @@ -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[] = [ + { + key: "field", + header: "Field", + render: (e) => {e.field}, + }, + { + key: "value", + header: "Value", + render: (e) => {e.value}, + }, + { + key: "confidence", + header: "Confidence", + align: "right", + width: "7rem", + render: (e) => ( + + {confidencePct(e.confidence)} + + ), + }, +]; + +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 ( +
+ + 🔒 + +

+ Extracted fields are hidden. Request timed access to view this + document's content. +

+
+ ); + } + + return ( + + columns={cols} + rows={doc.extractions} + rowKey={(e) => e.field} + empty="No fields were extracted from this document." + /> + ); +} diff --git a/frontend/portal/src/components/documents/DocumentOverview.stories.tsx b/frontend/portal/src/components/documents/DocumentOverview.stories.tsx new file mode 100644 index 0000000000..deaa6e28be --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentOverview.stories.tsx @@ -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 = { + title: "Portal/Documents/DocumentOverview", + component: DocumentOverview, + parameters: { layout: "padded" }, + args: { doc: DOC }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/portal/src/components/documents/DocumentOverview.tsx b/frontend/portal/src/components/documents/DocumentOverview.tsx new file mode 100644 index 0000000000..4ec3cac227 --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentOverview.tsx @@ -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 ( +
+
+ + + + + + +
+
+ ); +} diff --git a/frontend/portal/src/components/documents/DocumentsSummaryStrip.stories.tsx b/frontend/portal/src/components/documents/DocumentsSummaryStrip.stories.tsx new file mode 100644 index 0000000000..1bab1092be --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentsSummaryStrip.stories.tsx @@ -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 = { + title: "Portal/Documents/DocumentsSummaryStrip", + component: DocumentsSummaryStrip, + parameters: { layout: "padded" }, + args: { summary: summaryFor("enterprise"), loading: false }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { summary: null, loading: true }, +}; diff --git a/frontend/portal/src/components/documents/DocumentsSummaryStrip.tsx b/frontend/portal/src/components/documents/DocumentsSummaryStrip.tsx new file mode 100644 index 0000000000..8aed83eed4 --- /dev/null +++ b/frontend/portal/src/components/documents/DocumentsSummaryStrip.tsx @@ -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 ( + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ); + } + if (!summary) return null; + + return ( + + + + + + + ); +} diff --git a/frontend/portal/src/components/documents/ElevationBanner.stories.tsx b/frontend/portal/src/components/documents/ElevationBanner.stories.tsx new file mode 100644 index 0000000000..bcb1f26496 --- /dev/null +++ b/frontend/portal/src/components/documents/ElevationBanner.stories.tsx @@ -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 = { + title: "Portal/Documents/ElevationBanner", + component: ElevationBanner, + parameters: { layout: "padded" }, + args: { secondsLeft: null, fourEyes: false, onRequest: () => {} }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/documents/ElevationBanner.tsx b/frontend/portal/src/components/documents/ElevationBanner.tsx new file mode 100644 index 0000000000..b279ab76d6 --- /dev/null +++ b/frontend/portal/src/components/documents/ElevationBanner.tsx @@ -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 ( + ⏱} + 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 ( + 🔒} + 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={ + + } + /> + ); +} diff --git a/frontend/portal/src/components/documents/ReviewQueue.stories.tsx b/frontend/portal/src/components/documents/ReviewQueue.stories.tsx new file mode 100644 index 0000000000..2d1d37d88e --- /dev/null +++ b/frontend/portal/src/components/documents/ReviewQueue.stories.tsx @@ -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 = { + title: "Portal/Documents/ReviewQueue", + component: ReviewQueue, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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: [], + }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/documents/ReviewQueue.tsx b/frontend/portal/src/components/documents/ReviewQueue.tsx new file mode 100644 index 0000000000..43210a11ff --- /dev/null +++ b/frontend/portal/src/components/documents/ReviewQueue.tsx @@ -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 = { + 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(() => fetchDocuments(tier), [tier]); + const { data, loading } = state; + const { isLoading, isEmpty } = useSectionFlags(state); + + const [filter, setFilter] = useState("all"); + const [selectedId, setSelectedId] = useState(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[] = [ + { 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 ( +
+ + + + items={filterItems} + activeKey={filter} + onChange={setFilter} + variant="pill" + ariaLabel="Filter documents by status" + /> + + {isLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ )} + + {isEmpty && ( + + )} + + {!isLoading && !isEmpty && ( + setSelectedId(d.id)} + /> + )} + + setSelectedId(null)} /> +
+ ); +} diff --git a/frontend/portal/src/components/documents/ReviewQueueTable.stories.tsx b/frontend/portal/src/components/documents/ReviewQueueTable.stories.tsx new file mode 100644 index 0000000000..bf0f6bb640 --- /dev/null +++ b/frontend/portal/src/components/documents/ReviewQueueTable.stories.tsx @@ -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 = { + title: "Portal/Documents/ReviewQueueTable", + component: ReviewQueueTable, + parameters: { layout: "padded" }, + args: { documents: DOCS, onRowClick: () => {} }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +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: [] }, +}; diff --git a/frontend/portal/src/components/documents/ReviewQueueTable.tsx b/frontend/portal/src/components/documents/ReviewQueueTable.tsx new file mode 100644 index 0000000000..9c6d56a78f --- /dev/null +++ b/frontend/portal/src/components/documents/ReviewQueueTable.tsx @@ -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[]>( + () => [ + { + key: "name", + header: "Name", + render: (d) => ( +
+ {d.name} + {d.sensitive && ( + + 🔒 + + )} +
+ ), + }, + { key: "type", header: "Type", render: (d) => d.type }, + { + key: "status", + header: "Status", + render: (d) => ( + + {DOCUMENT_STATUS_LABEL[d.status]} + + ), + }, + { + key: "source", + header: "Source", + render: (d) => ( + {d.source} + ), + }, + { + key: "confidence", + header: "Confidence", + width: "9rem", + render: (d) => ( +
+ + + {confidencePct(d.confidence)} + +
+ ), + }, + { + key: "fields", + header: "Fields", + align: "right", + render: (d) => ( + {d.fieldsExtracted} + ), + }, + { + key: "time", + header: "Time", + align: "right", + render: (d) => ( + {d.time} + ), + }, + ], + [], + ); + + return ( + + className="portal-documents__table" + columns={columns} + rows={documents} + rowKey={(d) => d.id} + onRowClick={onRowClick} + empty="No documents match this filter." + /> + ); +} diff --git a/frontend/portal/src/components/documents/format.ts b/frontend/portal/src/components/documents/format.ts new file mode 100644 index 0000000000..d9591a3487 --- /dev/null +++ b/frontend/portal/src/components/documents/format.ts @@ -0,0 +1,28 @@ +import type { StatusTone } from "@shared/components"; + +/** Format a 0–1 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), 60–85% 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")}`; +} diff --git a/frontend/portal/src/components/editor-admin/CredentialRotationCard.stories.tsx b/frontend/portal/src/components/editor-admin/CredentialRotationCard.stories.tsx new file mode 100644 index 0000000000..29bcdeeb2d --- /dev/null +++ b/frontend/portal/src/components/editor-admin/CredentialRotationCard.stories.tsx @@ -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 = { + title: "Portal/EditorAdmin/CredentialRotationCard", + component: CredentialRotationCard, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +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", + }, + }, +}; diff --git a/frontend/portal/src/components/editor-admin/CredentialRotationCard.tsx b/frontend/portal/src/components/editor-admin/CredentialRotationCard.tsx new file mode 100644 index 0000000000..07dfda569a --- /dev/null +++ b/frontend/portal/src/components/editor-admin/CredentialRotationCard.tsx @@ -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 ( + +
+
+

Service token

+

+ Instances authenticate to the org with this credential. Rotate it on + a schedule or immediately after a suspected leak. +

+
+
+ +
+ + +
+ + {rotated && ( + + )} + +
+ +
+
+ ); +} diff --git a/frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.stories.tsx b/frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.stories.tsx new file mode 100644 index 0000000000..1dd63dc0a2 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.stories.tsx @@ -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 = { + title: "Portal/EditorAdmin/DeploymentSummaryStrip", + component: DeploymentSummaryStrip, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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 }, +}; diff --git a/frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.tsx b/frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.tsx new file mode 100644 index 0000000000..9491de67aa --- /dev/null +++ b/frontend/portal/src/components/editor-admin/DeploymentSummaryStrip.tsx @@ -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 ( + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ); + } + + return ( + + {summary.metrics.map((m) => ( + + ))} + + ); +} diff --git a/frontend/portal/src/components/editor-admin/DeploymentTargets.stories.tsx b/frontend/portal/src/components/editor-admin/DeploymentTargets.stories.tsx new file mode 100644 index 0000000000..68383a2e82 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/DeploymentTargets.stories.tsx @@ -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 = { + title: "Portal/EditorAdmin/DeploymentTargets", + component: DeploymentTargets, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Free: only Managed Cloud runs; Docker + K8s are locked behind a paywall. */ +export const Free: Story = { + args: { targets: buildEditorDeploymentResponse("free").targets }, + decorators: [ + (S) => ( + + + + ), + ], +}; + +/** Pro: Docker running, Kubernetes available but not yet deployed. */ +export const Pro: Story = { + args: { targets: buildEditorDeploymentResponse("pro").targets }, + decorators: [ + (S) => ( + + + + ), + ], +}; + +export const Enterprise: Story = { + args: { targets: buildEditorDeploymentResponse("enterprise").targets }, + decorators: [ + (S) => ( + + + + ), + ], +}; diff --git a/frontend/portal/src/components/editor-admin/DeploymentTargets.tsx b/frontend/portal/src/components/editor-admin/DeploymentTargets.tsx new file mode 100644 index 0000000000..6c326fb59b --- /dev/null +++ b/frontend/portal/src/components/editor-admin/DeploymentTargets.tsx @@ -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 ( +
+ {targets.map((t) => { + const meta = TARGET_META[t.kind]; + const badge = STATE_BADGE[t.state]; + const locked = t.state === "locked"; + return ( + +
+ + {meta.icon} + +
+

{t.label}

+

{t.tagline}

+
+ + {badge.label} + +
+ + {t.state === "running" && ( +

+ v{t.runningVersion} · {t.instanceCount}{" "} + {t.instanceCount === 1 ? "instance" : "instances"} +

+ )} + + {locked ? ( +
+

{lockCopy(t)}

+ +
+ ) : ( + + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/portal/src/components/editor-admin/InstanceHealthTable.stories.tsx b/frontend/portal/src/components/editor-admin/InstanceHealthTable.stories.tsx new file mode 100644 index 0000000000..940cac5eb0 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/InstanceHealthTable.stories.tsx @@ -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 = { + title: "Portal/EditorAdmin/InstanceHealthTable", + component: InstanceHealthTable, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** 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: [] }, +}; diff --git a/frontend/portal/src/components/editor-admin/InstanceHealthTable.tsx b/frontend/portal/src/components/editor-admin/InstanceHealthTable.tsx new file mode 100644 index 0000000000..90f45b5562 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/InstanceHealthTable.tsx @@ -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 = { + cloud: "Cloud", + docker: "Docker", + kubernetes: "K8s", +}; + +const cols: TableColumn[] = [ + { + key: "host", + header: "Host", + render: (i) => ( +
+ {i.host} + + + {TARGET_LABEL[i.target]} + + +
+ ), + }, + { + key: "version", + header: "Version", + render: (i) => {i.version}, + }, + { + key: "region", + header: "Region", + render: (i) => {i.region}, + }, + { + key: "status", + header: "Status", + render: (i) => ( + + {INSTANCE_STATUS_LABEL[i.status]} + + ), + }, + { + key: "lastSeen", + header: "Last seen", + render: (i) => {i.lastSeen}, + }, + { + key: "activeUsers", + header: "Active users", + align: "right", + render: (i) => {i.activeUsers}, + }, +]; + +interface Props { + instances: EditorInstance[]; +} + +/** Live health for every Editor instance reporting in to the org. */ +export function InstanceHealthTable({ instances }: Props) { + return ( + + {instances.length === 0 ? ( + + ) : ( +
i.id} /> + )} + + ); +} diff --git a/frontend/portal/src/components/editor-admin/OfflineActivationCard.stories.tsx b/frontend/portal/src/components/editor-admin/OfflineActivationCard.stories.tsx new file mode 100644 index 0000000000..4f2f94bfb7 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/OfflineActivationCard.stories.tsx @@ -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 = { + title: "Portal/EditorAdmin/OfflineActivationCard", + component: OfflineActivationCard, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx b/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx new file mode 100644 index 0000000000..cd7370cbe2 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx @@ -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 ( + +
+
+

+ Air-gapped activation + Enterprise +

+

+ 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. +

+
+
+ + {!available ? ( +
+

+ Offline and on-prem activation is part of Enterprise. +

+ +
+ ) : ( + <> + {generated && ( + + )} +
+ +
+ + )} +
+ ); +} diff --git a/frontend/portal/src/components/editor-admin/PairingPanel.stories.tsx b/frontend/portal/src/components/editor-admin/PairingPanel.stories.tsx new file mode 100644 index 0000000000..fce1446e97 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/PairingPanel.stories.tsx @@ -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 = { + title: "Portal/EditorAdmin/PairingPanel", + component: PairingPanel, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** 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 }, +}; diff --git a/frontend/portal/src/components/editor-admin/PairingPanel.tsx b/frontend/portal/src/components/editor-admin/PairingPanel.tsx new file mode 100644 index 0000000000..0ddcc31050 --- /dev/null +++ b/frontend/portal/src/components/editor-admin/PairingPanel.tsx @@ -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 = { + 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(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 ( +
+ {pairings.map((p) => { + const isCode = p.method === "iac"; + return ( + +
+ + {METHOD_ICON[p.method]} + +
+

{p.label}

+

{p.description}

+
+
+ + {p.locked ? ( +
+

+ IaC provisioning is part of Enterprise. +

+ +
+ ) : ( + <> + {isCode ? ( + + ) : ( +
+ {p.value} +
+ )} +
+ {p.expires && ( + + {p.expires} + + )} + +
+ + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/portal/src/components/icons.tsx b/frontend/portal/src/components/icons.tsx index 0018850c57..67051cab1f 100644 --- a/frontend/portal/src/components/icons.tsx +++ b/frontend/portal/src/components/icons.tsx @@ -200,3 +200,46 @@ export function SendIcon(props: IconProps) { ); } + +export function UsersIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function PoliciesIcon(props: IconProps) { + return ( + + + + + ); +} + +export function ComponentsIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function AgentBuilderIcon(props: IconProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/portal/src/components/infrastructure/ModelsTab.stories.tsx b/frontend/portal/src/components/infrastructure/ModelsTab.stories.tsx new file mode 100644 index 0000000000..c69fd63988 --- /dev/null +++ b/frontend/portal/src/components/infrastructure/ModelsTab.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +/** 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" }, +}; diff --git a/frontend/portal/src/components/infrastructure/ModelsTab.tsx b/frontend/portal/src/components/infrastructure/ModelsTab.tsx new file mode 100644 index 0000000000..f807941a6b --- /dev/null +++ b/frontend/portal/src/components/infrastructure/ModelsTab.tsx @@ -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[] = [ + { + key: "name", + header: "Model", + render: (m) => ( +
+ {m.name} + + {MODEL_PROVIDER_LABEL[m.provider]} + +
+ ), + }, + { + key: "type", + header: "Type", + render: (m) => ( + + {MODEL_TYPE_LABEL[m.type]} + + ), + }, + { + key: "status", + header: "Status", + render: (m) => ( + + {MODEL_LABEL[m.status]} + + ), + }, + { + key: "load", + header: "Load", + width: "9rem", + render: (m) => ( +
+ + {pct(m.load)} +
+ ), + }, + { + key: "latency", + header: "Latency", + align: "right", + render: (m) => {m.latencyMs} ms, + }, + { + key: "cost", + header: "Cost", + align: "right", + render: (m) => ( + + {modelCost(m.cost, m.costUnit)} + + ), + }, + { + key: "version", + header: "Version", + render: (m) => {m.version}, + }, +]; + +export function ModelsTab() { + const { tier } = useTier(); + const state = useAsync(() => 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[] = [ + { + key: "operation", + header: "Operation", + render: (r) => ( +
+ {r.operation} + {r.isDefault && ( + + Default + + )} +
+ ), + }, + { key: "docType", header: "Document type", render: (r) => r.docType }, + { + key: "modelId", + header: "Routed to", + width: "16rem", + render: (r) => ( +
m.id} + /> + )} + + + + {tier === "enterprise" && ( + + )} + +
+ + {canRoute ? ( + + {isLoading && } + {!isEmpty && data && ( +
r.id} + empty="No routing rules configured." + /> + )} + + ) : ( + + )} + + + ); +} diff --git a/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx b/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx index a0a732b953..324c841043 100644 --- a/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx +++ b/frontend/portal/src/components/infrastructure/SecurityTab.stories.tsx @@ -20,6 +20,18 @@ type Story = StoryObj; 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: { diff --git a/frontend/portal/src/components/infrastructure/SecurityTab.tsx b/frontend/portal/src/components/infrastructure/SecurityTab.tsx index 081e8f24ed..1ca703e013 100644 --- a/frontend/portal/src/components/infrastructure/SecurityTab.tsx +++ b/frontend/portal/src/components/infrastructure/SecurityTab.tsx @@ -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[] = [ @@ -146,6 +152,74 @@ export function SecurityTab() { +
+ + +
+
+ + {data.keyManagement.provider} + + + {KEY_MODE_LABEL[data.keyManagement.mode]} + +
+ {/* Rotation is a privileged backend action; disabled where Stirling + holds the keys (managed tiers can't rotate customer keys). */} + +
+ +
+
+
Key identifier
+
+ + {data.keyManagement.keyId} + +
+
+
+
Algorithm
+
+ {data.keyManagement.algorithm} +
+
+
+
Last rotated
+
{data.keyManagement.lastRotated}
+
+
+
Rotation policy
+
{data.keyManagement.rotationPolicy}
+
+
+ + {!data.keyManagement.customerManaged && ( + + )} +
+
+
+
+ +
+ {data.attestations.map((a) => ( + +
+ {a.name} + + {ATTESTATION_LABEL[a.status]} + +
+ + {a.framework} + +

{a.detail}

+ {a.reportUrl ? ( + e.preventDefault()} + > + View report → + + ) : ( + No report available + )} +
+ ))} +
+
+
= { "not-started": "Not started", }; +export const KEY_MODE_LABEL: Record = { + managed: "Stirling-managed", + byok: "BYOK", + hyok: "HYOK", +}; + +export const KEY_MODE_TONE: Record = { + managed: "info", + byok: "purple", + // HYOK is the strongest posture (Stirling never sees plaintext) → success. + hyok: "success", +}; + +export const ATTESTATION_LABEL: Record = { + attested: "Attested", + "in-scope": "In scope", + "not-applicable": "N/A", +}; + +export const ATTESTATION_TONE: Record = { + attested: "success", + "in-scope": "warning", + "not-applicable": "neutral", +}; + export const AUDIT_TONE: Record = { success: "success", warning: "warning", @@ -84,3 +115,43 @@ export const AUDIT_CAT_TONE: Record = { processing: "success", security: "warning", }; + +export const MODEL_TONE: Record = { + active: "success", + degraded: "warning", + disabled: "neutral", +}; + +export const MODEL_LABEL: Record = { + active: "Active", + degraded: "Degraded", + disabled: "Disabled", +}; + +export const MODEL_TYPE_LABEL: Record = { + extraction: "Extraction", + classification: "Classification", + ocr: "OCR", + llm: "LLM", +}; + +export const MODEL_TYPE_TONE: Record = { + extraction: "blue", + classification: "purple", + ocr: "green", + llm: "amber", +}; + +export const MODEL_PROVIDER_LABEL: Record = { + 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`; +} diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx new file mode 100644 index 0000000000..3dc731a47a --- /dev/null +++ b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.stories.tsx @@ -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 = { + title: "Portal/Pipelines/DeployedPipelinesTable", + component: DeployedPipelinesTable, + parameters: { layout: "padded" }, + args: { onRowClick: () => {} }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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: [] }, +}; diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx new file mode 100644 index 0000000000..0ad463c3a1 --- /dev/null +++ b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx @@ -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[]>( + () => [ + { + key: "name", + header: "Pipeline", + render: (p) => ( +
+ {p.name} + + {p.source} → {p.destination} + +
+ ), + }, + { + key: "status", + header: "Health", + render: (p) => ( + + {p.status === "degraded" ? "Degraded" : "Healthy"} + + ), + }, + { + 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 ( +
+ + {p.golden.passing}/{p.golden.total} + + + {pct(rate, 1)} + +
+ ); + }, + }, + { + key: "docs", + header: "Docs / 24h", + align: "right", + render: (p) => ( + + {compact(p.metrics.docs24h)} + + ), + }, + { + key: "version", + header: "Version", + align: "right", + render: (p) => ( + {p.version} + ), + }, + ], + [], + ); + + return ( + + className="portal-pipelines__roster" + columns={columns} + rows={pipelines} + rowKey={(p) => p.id} + onRowClick={onRowClick} + /> + ); +} diff --git a/frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx b/frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx new file mode 100644 index 0000000000..bf52ce686f --- /dev/null +++ b/frontend/portal/src/components/pipelines/PromotedPipelines.stories.tsx @@ -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 = { + title: "Portal/Pipelines/PromotedPipelines", + component: PromotedPipelines, + parameters: { layout: "padded" }, + args: { promoted: PROMOTED_PIPELINES }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { promoted: [] }, +}; diff --git a/frontend/portal/src/components/pipelines/PromotedPipelines.tsx b/frontend/portal/src/components/pipelines/PromotedPipelines.tsx new file mode 100644 index 0000000000..a05112fc58 --- /dev/null +++ b/frontend/portal/src/components/pipelines/PromotedPipelines.tsx @@ -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 = { + deployed: "success", + staged: "info", + review: "warning", +}; + +const STATUS_LABEL: Record = { + 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 + >({}); + + 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[]>( + () => [ + { + key: "name", + header: "Pipeline", + render: (p) => ( +
+ {p.name} + + {p.promotedAt} + +
+ ), + }, + { + key: "docType", + header: "Source doc type", + render: (p) => ( + + {p.sourceDocType} + + ), + }, + { + key: "watchFolder", + header: "Watch folder", + render: (p) => ( + + {p.watchFolder} + + ), + }, + { + key: "status", + header: "Status", + render: (p) => ( + + {STATUS_LABEL[p.status]} + + ), + }, + { + key: "promote", + header: "", + align: "right", + width: "11rem", + render: (p) => { + const state = promoteState[p.id] ?? "idle"; + if (state === "done") { + return ( + + Policy created + + ); + } + return ( + + ); + }, + }, + ], + [promoteState], + ); + + return ( + + className="portal-pipelines__promoted" + columns={columns} + rows={promoted} + rowKey={(p) => p.id} + /> + ); +} diff --git a/frontend/portal/src/components/pipelines/format.ts b/frontend/portal/src/components/pipelines/format.ts index ac38f33ec3..02267eb8b5 100644 --- a/frontend/portal/src/components/pipelines/format.ts +++ b/frontend/portal/src/components/pipelines/format.ts @@ -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"; +} diff --git a/frontend/portal/src/components/pipelines/storyFixtures.ts b/frontend/portal/src/components/pipelines/storyFixtures.ts index 39458ce4d8..1973edbae1 100644 --- a/frontend/portal/src/components/pipelines/storyFixtures.ts +++ b/frontend/portal/src/components/pipelines/storyFixtures.ts @@ -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", + }, +]; diff --git a/frontend/portal/src/components/policies/CatalogueSummary.stories.tsx b/frontend/portal/src/components/policies/CatalogueSummary.stories.tsx new file mode 100644 index 0000000000..75acb1cd2d --- /dev/null +++ b/frontend/portal/src/components/policies/CatalogueSummary.stories.tsx @@ -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 = { + title: "Portal/Policies/CatalogueSummary", + component: CatalogueSummary, + parameters: { layout: "padded" }, + args: { data: RESPONSE, loading: false }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No data yet — every tile shows the em-dash placeholder. */ +export const Loading: Story = { args: { data: null, loading: true } }; diff --git a/frontend/portal/src/components/policies/CatalogueSummary.tsx b/frontend/portal/src/components/policies/CatalogueSummary.tsx new file mode 100644 index 0000000000..005696da91 --- /dev/null +++ b/frontend/portal/src/components/policies/CatalogueSummary.tsx @@ -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 ( + + + + + + + ); +} diff --git a/frontend/portal/src/components/policies/PolicyCategoryCard.stories.tsx b/frontend/portal/src/components/policies/PolicyCategoryCard.stories.tsx new file mode 100644 index 0000000000..139a88daaf --- /dev/null +++ b/frontend/portal/src/components/policies/PolicyCategoryCard.stories.tsx @@ -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 = { + title: "Portal/Policies/PolicyCategoryCard", + component: PolicyCategoryCard, + parameters: { layout: "padded" }, + args: { onOpen: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** 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, + }, + }, +}; diff --git a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx new file mode 100644 index 0000000000..38e06a357a --- /dev/null +++ b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx @@ -0,0 +1,100 @@ +import { Card, Chip, StatusBadge, StatTile } from "@shared/components"; +import type { CatalogueEntry } from "@portal/api/policies"; +import { policyIcon } from "@portal/components/policies/policyIcons"; +import "@portal/views/Policies.css"; + +interface PolicyCategoryCardProps { + entry: CatalogueEntry; + onOpen: (entry: CatalogueEntry) => void; +} + +/** + * One card per policy category. Configured categories show the live status + + * stats and open the detail panel; unconfigured ones show the summary + a + * "Set up" affordance; coming-soon categories render locked and inert. + */ +export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { + const { category, config, policy } = entry; + const comingSoon = category.comingSoon === true; + const openable = !comingSoon; + const status = policy?.state.status; + + return ( + onOpen(entry) : undefined} + role={openable ? "button" : undefined} + tabIndex={openable ? 0 : undefined} + onKeyDown={ + openable + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(entry); + } + } + : undefined + } + > +
+ + {policyIcon(category.icon)} + +
+

{category.label}

+ {category.desc} +
+ {comingSoon ? ( + + Coming soon + + ) : policy ? ( + + {status === "paused" ? "Paused" : "Active"} + + ) : ( + + Not set up + + )} +
+ +

{config.summary}

+ + {policy ? ( +
+ + + +
+ ) : ( +
+
+ {config.rules.slice(0, 3).map((rule) => ( + + {rule} + + ))} +
+ {!comingSoon && ( + Set up → + )} +
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx new file mode 100644 index 0000000000..215c319ba4 --- /dev/null +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { decorateForStory } from "@portal/components/policies/storyFixtures"; +import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; + +const meta: Meta = { + title: "Portal/Policies/PolicyDetailPanel", + component: PolicyDetailPanel, + parameters: { layout: "fullscreen" }, + args: { + onClose: () => {}, + onEdit: () => {}, + onRun: () => {}, + onTogglePause: () => {}, + onDelete: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** An active built-in policy — Delete is hidden (default policies aren't deletable). */ +export const Active: Story = { + args: { policy: decorateForStory("security") }, +}; + +/** A paused policy — the action reads "Resume". */ +export const Paused: Story = { + args: { + policy: { + ...decorateForStory("security"), + state: { ...decorateForStory("security").state, status: "paused" }, + }, + }, +}; + +/** A custom (deletable) policy with no runs yet — empty activity feed. */ +export const CustomNoActivity: Story = { + args: { + policy: { + ...decorateForStory("security"), + state: { ...decorateForStory("security").state, isDefault: false }, + activity: [], + stats: { enforced: 0, dataProcessed: "0 B", activeFor: "—" }, + }, + }, +}; diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx new file mode 100644 index 0000000000..da0fa06e39 --- /dev/null +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx @@ -0,0 +1,206 @@ +import { + Banner, + Button, + Card, + Chip, + EmptyState, + Modal, + StatTile, + StatusBadge, +} from "@shared/components"; +import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies"; +import { policyIcon } from "@portal/components/policies/policyIcons"; +import "@portal/views/Policies.css"; + +interface PolicyDetailPanelProps { + /** The configured policy being viewed, or null when closed. */ + policy: DecoratedPolicy | null; + /** Whether a lifecycle action (run/pause/delete) is in flight. */ + busy?: boolean; + onClose: () => void; + onEdit: () => void; + onRun: () => void; + onTogglePause: () => void; + onDelete: () => void; +} + +const ACTIVITY_TONE = { + enforced: "success", + flagged: "warning", + processing: "info", +} as const; + +/** + * Narrative view for a configured policy: the enforced tool chain, recent + * activity, summary stats, and the lifecycle actions (run now, pause/resume, + * delete). Built-in (default) policies hide Delete — they're configurable but + * not deletable, matching the backend. + */ +export function PolicyDetailPanel({ + policy, + busy = false, + onClose, + onEdit, + onRun, + onTogglePause, + onDelete, +}: PolicyDetailPanelProps) { + if (!policy) return null; + const { category, config, state, steps, stats, activity } = policy; + const isPaused = state.status === "paused"; + const canDelete = state.isDefault !== true; + const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : []; + + return ( + + + {policyIcon(category.icon)} + + {category.label} policy + + } + subtitle={config.summary} + footer={ +
+ {canDelete && ( + + )} + + + +
+ } + > +
+ + {isPaused ? "Paused" : "Active"} + + + Runs on {state.runOn ?? "upload"} · output{" "} + {state.outputMode === "new_file" + ? "as a new file" + : "as a new version"} + +
+ +

Enforces

+ + {enforceItems.length > 0 ? ( +
+ {enforceItems.map((op, i) => ( + + {i > 0 && ( + + → + + )} + + {humanizeEndpoint(op)} + + + ))} +
+ ) : ( +
+ {config.rules.map((rule) => ( + + {rule} + + ))} +
+ )} +

+ {config.scopeLabel} · originals stay untouched, the enforced version + is saved alongside. +

+
+ +

Recent activity

+ {activity.length > 0 ? ( + + {activity.map((item, i) => ( +
+ + + + {item.doc} + + + {item.action} + + + + {item.time} + +
+ ))} +
+ ) : ( + + + + )} + + + + + + + + {state.scopeTypes.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/portal/src/components/policies/PolicyFieldRow.stories.tsx b/frontend/portal/src/components/policies/PolicyFieldRow.stories.tsx new file mode 100644 index 0000000000..e6a8a4bb4d --- /dev/null +++ b/frontend/portal/src/components/policies/PolicyFieldRow.stories.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { PolicyField } from "@portal/api/policies"; +import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; + +const meta: Meta = { + title: "Portal/Policies/PolicyFieldRow", + component: PolicyFieldRow, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Renders the field and keeps its value in local state, exercising onChange. */ +function Controlled({ field }: { field: PolicyField }) { + const [value, setValue] = useState(field.value); + return ; +} + +export const Toggle: Story = { + render: () => ( + + ), +}; + +export const Select: Story = { + render: () => ( + + ), +}; + +export const Chips: Story = { + render: () => ( + + ), +}; + +export const Text: Story = { + render: () => ( + + ), +}; diff --git a/frontend/portal/src/components/policies/PolicyFieldRow.tsx b/frontend/portal/src/components/policies/PolicyFieldRow.tsx new file mode 100644 index 0000000000..7cfd295072 --- /dev/null +++ b/frontend/portal/src/components/policies/PolicyFieldRow.tsx @@ -0,0 +1,88 @@ +import { + Chip, + FormField, + Input, + Select, + ToggleSwitch, +} from "@shared/components"; +import type { PolicyField } from "@portal/api/policies"; +import "@portal/views/Policies.css"; + +interface PolicyFieldRowProps { + field: PolicyField; + /** Effective current value (override or definition default). */ + value: boolean | string | string[]; + onChange: (value: boolean | string | string[]) => void; +} + +/** + * Renders one policy setting from the catalogue's `PolicyField`, dispatching on + * `type`: toggle → ToggleSwitch, select → Select, chips → multi-select Chips, + * text → Input. Controlled — the setup flow owns the value. + */ +export function PolicyFieldRow({ + field, + value, + onChange, +}: PolicyFieldRowProps) { + if (field.type === "toggle") { + return ( +
+ +
+ ); + } + + if (field.type === "chips") { + const selected = Array.isArray(value) ? value : []; + const toggle = (opt: string) => + onChange( + selected.includes(opt) + ? selected.filter((o) => o !== opt) + : [...selected, opt], + ); + return ( + +
+ {(field.options ?? []).map((opt) => ( + toggle(opt)} + > + {opt} + + ))} +
+
+ ); + } + + if (field.type === "select") { + return ( + + onChange(e.target.value)} + /> + + ); +} diff --git a/frontend/portal/src/components/policies/PolicySetupWizard.stories.tsx b/frontend/portal/src/components/policies/PolicySetupWizard.stories.tsx new file mode 100644 index 0000000000..275a8c9d15 --- /dev/null +++ b/frontend/portal/src/components/policies/PolicySetupWizard.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + POLICY_CATEGORIES, + POLICY_CONFIG, + decorateForStory, +} from "@portal/components/policies/storyFixtures"; +import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; + +const security = POLICY_CATEGORIES.find((c) => c.id === "security")!; + +const meta: Meta = { + title: "Portal/Policies/PolicySetupWizard", + component: PolicySetupWizard, + parameters: { layout: "fullscreen" }, + args: { + onClose: () => {}, + onSubmit: async () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** First-time setup — seeded from the category's preset tool chain. */ +export const Create: Story = { + args: { + entry: { category: security, config: POLICY_CONFIG.security, policy: null }, + }, +}; + +/** Editing a configured policy — pre-filled from its saved steps + settings. */ +export const Edit: Story = { + args: { + entry: { + category: security, + config: POLICY_CONFIG.security, + policy: decorateForStory("security"), + }, + }, +}; diff --git a/frontend/portal/src/components/policies/PolicySetupWizard.tsx b/frontend/portal/src/components/policies/PolicySetupWizard.tsx new file mode 100644 index 0000000000..35df6b8a3d --- /dev/null +++ b/frontend/portal/src/components/policies/PolicySetupWizard.tsx @@ -0,0 +1,461 @@ +import { useMemo, useState } from "react"; +import { + Banner, + Button, + Card, + Chip, + FormField, + Input, + Modal, + Select, + Tabs, + ToggleSwitch, +} from "@shared/components"; +import { + POLICY_DOC_TYPES, + POLICY_SOURCES, + humanizeEndpoint, + type CatalogueEntry, + type PipelineStep, + type PolicySetupResult, +} from "@portal/api/policies"; +import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; +import { policyIcon } from "@portal/components/policies/policyIcons"; +import "@portal/views/Policies.css"; + +interface PolicySetupWizardProps { + /** The category being configured, or null when closed. */ + entry: CatalogueEntry | null; + onClose: () => void; + /** + * Fires on submit with the collected settings + built pipeline steps. May be + * async; if it rejects the wizard re-enables submit and surfaces the failure. + */ + onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise; +} + +type Step = "workflow" | "settings"; + +/** A configurable tool in the workflow step: whether it runs + its params. */ +interface ToolState { + operation: string; + enabled: boolean; + parameters: Record; +} + +/** Resolve each field's effective value: saved override, else definition default. */ +function resolveFieldValues( + entry: CatalogueEntry, +): Record { + const saved = entry.policy?.state.fieldValues ?? {}; + const out: Record = {}; + for (const f of entry.config.fields) out[f.key] = saved[f.key] ?? f.value; + return out; +} + +/** + * Seed the workflow's tools. A configured policy's saved steps win (so editing + * round-trips); otherwise the category preset's default chain. Each preset step + * starts enabled — the user toggles tools off in the workflow. + */ +function seedTools(entry: CatalogueEntry): ToolState[] { + const source = entry.policy?.steps?.length + ? entry.policy.steps + : entry.config.defaultOperations; + return source.map((s) => ({ + operation: s.operation, + enabled: true, + parameters: s.parameters, + })); +} + +/** + * The real "set up a policy" flow, mirroring the editor wizard: a Workflow step + * (the tool chain — toggle which tools run) and a Settings step (policy fields, + * sources, scope, reviewer, output/run). Submitting builds the pipeline steps + * (each `operation` an endpoint path) and persists via the real POST. + */ +export function PolicySetupWizard({ + entry, + onClose, + onSubmit, +}: PolicySetupWizardProps) { + // Re-key the wizard on the opened category so all state resets cleanly when a + // different category is opened (avoids stale field values bleeding across). + return entry ? ( + + ) : null; +} + +function PolicySetupWizardBody({ + entry, + onClose, + onSubmit, +}: { + entry: CatalogueEntry; + onClose: () => void; + onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise; +}) { + const { category, config, policy } = entry; + const isEdit = policy != null; + + const [step, setStep] = useState("workflow"); + const [tools, setTools] = useState(() => seedTools(entry)); + const [fieldValues, setFieldValues] = useState(() => + resolveFieldValues(entry), + ); + const [sources, setSources] = useState( + policy?.state.sources.length ? policy.state.sources : ["editor"], + ); + const [scopeNarrow, setScopeNarrow] = useState( + (policy?.state.scopeTypes.length ?? 0) > 0, + ); + const [scopeTypes, setScopeTypes] = useState( + policy?.state.scopeTypes ?? [], + ); + const [reviewerEmail, setReviewerEmail] = useState( + policy?.state.reviewerEmail ?? "you@acme.com", + ); + const [outputMode, setOutputMode] = useState<"new_file" | "new_version">( + policy?.state.outputMode ?? "new_version", + ); + const [outputName, setOutputName] = useState(policy?.state.outputName ?? ""); + const [outputNamePosition, setOutputNamePosition] = useState< + "prefix" | "suffix" | "auto-number" + >("suffix"); + const [runOn, setRunOn] = useState<"upload" | "export">( + policy?.state.runOn ?? "upload", + ); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]); + + function patchTool(operation: string, patch: Partial) { + setTools((prev) => + prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)), + ); + } + + function toggleSource(id: string) { + setSources((prev) => + prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id], + ); + } + + function toggleScopeType(dt: string) { + setScopeTypes((prev) => + prev.includes(dt) ? prev.filter((d) => d !== dt) : [...prev, dt], + ); + } + + async function submit() { + if (submitting) return; + if (enabledTools.length === 0) { + setError("Enable at least one tool in the workflow first."); + setStep("workflow"); + return; + } + setError(null); + setSubmitting(true); + const steps: PipelineStep[] = enabledTools.map((tl) => ({ + operation: tl.operation, + parameters: tl.parameters, + })); + try { + await onSubmit(entry, { + fieldValues, + sources, + scopeTypes: scopeNarrow ? scopeTypes : [], + reviewerEmail, + outputMode, + outputName: outputName.trim(), + outputNamePosition, + runOn, + steps, + }); + } catch { + setSubmitting(false); + setError("Couldn't save the policy. Please try again."); + } + } + + const docTypesEnabled = category.providesClassification === true; + + return ( + + + {policyIcon(category.icon)} + + {isEdit + ? `Edit ${category.label} policy` + : `Set up ${category.label} policy`} + + } + subtitle={config.summary} + footer={ +
+ + {step === "workflow" ? ( + + ) : ( + <> + + + + )} +
+ } + > + setStep(k as Step)} + items={[ + { key: "workflow", label: "Workflow" }, + { key: "settings", label: "Settings" }, + ]} + /> + + {error && ( + + )} + + {step === "workflow" && ( +
+

+ The sequence of tools this policy runs on each document. Each tool + is a Stirling endpoint; toggle the ones this policy should enforce. +

+ {tools.map((tl) => ( + +
+ + {humanizeEndpoint(tl.operation)} + + + {tl.operation} + + + patchTool(tl.operation, { enabled: checked }) + } + label="" + /> +
+
+ ))} +
+ )} + + {step === "settings" && ( +
+ {config.fields.length > 0 && ( + <> +

Settings

+
+ {config.fields.map((field) => ( + + setFieldValues((prev) => ({ ...prev, [field.key]: v })) + } + /> + ))} +
+ + )} + +

Sources

+
+ {POLICY_SOURCES.map((src) => ( + + ))} +
+ +

Document types

+ {!docTypesEnabled ? ( + + ) : ( + +
+ + {scopeTypes.length === 0 + ? "All document types" + : `${scopeTypes.length} selected`} + + +
+ {scopeNarrow && ( +
+ {POLICY_DOC_TYPES.map((dt) => ( + toggleScopeType(dt)} + > + {dt} + + ))} +
+ )} +
+ )} + +

Output & run

+
+ + { + const mode = e.target.value as "new_file" | "new_version"; + setOutputMode(mode); + // Auto-number only applies to separate new files. + if ( + mode === "new_version" && + outputNamePosition === "auto-number" + ) { + setOutputNamePosition("suffix"); + } + }} + options={[ + { value: "new_version", label: "New version" }, + { value: "new_file", label: "New file" }, + ]} + /> + + +
+ setOutputName(e.target.value)} + /> + )} +
+
+ + setReviewerEmail(e.target.value)} + /> + +
+
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/policies/policyIcons.ts b/frontend/portal/src/components/policies/policyIcons.ts new file mode 100644 index 0000000000..5ab6113b49 --- /dev/null +++ b/frontend/portal/src/components/policies/policyIcons.ts @@ -0,0 +1,25 @@ +/** + * Glyphs for the policy catalogue's string icon keys. The catalogue model + * carries semantic keys (e.g. "shield", "layers") rather than React nodes so + * the data stays portable; the portal owns the rendering and maps each key to a + * glyph here. + */ +export const POLICY_ICON_GLYPHS: Record = { + layers: "▤", + shield: "🛡", + check: "✓", + route: "⇉", + clock: "⏲", + // Source icons. + file: "▢", + device: "▣", + globe: "◍", + cloud: "☁", + mail: "✉", + folder: "▤", +}; + +/** Resolve an icon key to its glyph, falling back to a neutral dot. */ +export function policyIcon(key: string): string { + return POLICY_ICON_GLYPHS[key] ?? "•"; +} diff --git a/frontend/portal/src/components/policies/storyFixtures.ts b/frontend/portal/src/components/policies/storyFixtures.ts new file mode 100644 index 0000000000..ec1c3ddffe --- /dev/null +++ b/frontend/portal/src/components/policies/storyFixtures.ts @@ -0,0 +1,40 @@ +/** + * Shared fixtures for the policy component stories — a configured, decorated + * policy built straight from the catalogue + seed data, so stories render the + * same shapes the MSW handlers serve without standing up the whole API. + */ +import { + POLICY_CATEGORIES, + POLICY_CONFIG, + seedRuntime, + type DecoratedPolicy, +} from "@portal/mocks/policies"; + +export { POLICY_CATEGORIES, POLICY_CONFIG }; + +/** A decorated, active policy for a category, mirroring the handler's decorate(). */ +export function decorateForStory(categoryId: string): DecoratedPolicy { + const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!; + const config = POLICY_CONFIG[categoryId]; + const rt = seedRuntime().pol_security_default; + return { + category, + config, + state: { + configured: true, + status: "active", + sources: ["editor"], + scopeTypes: [], + reviewerEmail: rt.reviewerEmail, + fieldValues: {}, + outputMode: "new_version", + outputName: "", + runOn: "upload", + backendId: "pol_story", + isDefault: true, + }, + steps: config.defaultOperations, + stats: rt.stats, + activity: rt.activity, + }; +} diff --git a/frontend/portal/src/components/users/AccessControls.stories.tsx b/frontend/portal/src/components/users/AccessControls.stories.tsx new file mode 100644 index 0000000000..45f69ec8a3 --- /dev/null +++ b/frontend/portal/src/components/users/AccessControls.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { accessFor } from "@portal/mocks/users"; +import { AccessControls } from "@portal/components/users/AccessControls"; + +const meta: Meta = { + title: "Portal/Users/AccessControls", + component: AccessControls, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Free: seat limit + upgrade nudge only. */ +export const Free: Story = { + args: { access: accessFor("free") }, +}; + +/** Pro: adds self-service MFA + session toggles. */ +export const Pro: Story = { + args: { access: accessFor("pro") }, +}; + +/** Enterprise: SSO/SAML, SCIM provisioning and enforced MFA. */ +export const Enterprise: Story = { + args: { access: accessFor("enterprise") }, +}; diff --git a/frontend/portal/src/components/users/AccessControls.tsx b/frontend/portal/src/components/users/AccessControls.tsx new file mode 100644 index 0000000000..ede0720e94 --- /dev/null +++ b/frontend/portal/src/components/users/AccessControls.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { + Banner, + Button, + Card, + ProgressBar, + StatTile, + StatusBadge, + ToggleSwitch, +} from "@shared/components"; +import type { AccessControls as Access } from "@portal/api/users"; +import { seatsLabel } from "@portal/components/users/format"; +import "@portal/views/Users.css"; + +interface AccessControlsProps { + access: Access; +} + +/** + * Access posture for the org, scaling by tier: + * free — seat limit + upgrade nudge only + * pro — adds self-service MFA + session timeout toggles + * enterprise — adds SSO/SAML, SCIM provisioning and enforced MFA + * + * Toggles hold local state only; persisting them is a backend wiring task. + */ +export function AccessControls({ access }: AccessControlsProps) { + const [mfaEnforced, setMfaEnforced] = useState(access.mfaEnforced ?? false); + const [shortSessions, setShortSessions] = useState(false); + + const seatPct = + access.seatLimit === null + ? 0 + : Math.min(1, access.seatsUsed / access.seatLimit); + + return ( +
+
+

Access & security

+

+ Seats, authentication and provisioning for your organization. +

+
+ +
+ {/* Seats — shown on every tier. */} + +
+

Seats

+ + {seatsLabel(access.seatsUsed, access.seatLimit)} + +
+ {access.seatLimit === null ? ( +

+ Your plan includes unlimited seats. +

+ ) : ( + + )} +
+ + {/* Pro+: MFA + sessions self-service. */} + {access.mfaAvailable && ( + +

Authentication

+
+
+ { + setMfaEnforced(v); + // TODO(backend): PATCH /v1/users/access { mfaEnforced } + }} + label="Require MFA" + description={ + access.mfaEnforced + ? "Enforced org-wide on this plan." + : "Members must set up a second factor to sign in." + } + disabled={access.mfaEnforced} + /> +
+
+ { + setShortSessions(v); + // TODO(backend): PATCH /v1/users/access { sessionTimeout } + }} + label="Short-lived sessions" + description={`Sign members out after inactivity (currently ${access.sessionTimeout}).`} + /> +
+
+
+ )} + + {/* Enterprise: SSO. */} + {access.sso && ( + +
+

SSO / SAML

+ + {access.sso.status === "connected" + ? "Connected" + : "Not configured"} + +
+
+ + +
+ +
+ )} + + {/* Enterprise: SCIM provisioning. */} + {access.scim && ( + +
+

+ SCIM provisioning +

+ + {access.scim.enabled ? "Active" : "Off"} + +
+
+ + +
+

+ Members are created, updated and deactivated automatically from + your identity provider. +

+
+ )} +
+ + {/* Free: upgrade nudge spans the section. */} + {access.upgradeHint && ( + + Upgrade plan + + } + /> + )} +
+ ); +} diff --git a/frontend/portal/src/components/users/InviteMemberModal.stories.tsx b/frontend/portal/src/components/users/InviteMemberModal.stories.tsx new file mode 100644 index 0000000000..cffa89d54d --- /dev/null +++ b/frontend/portal/src/components/users/InviteMemberModal.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; + +const meta: Meta = { + title: "Portal/Users/InviteMemberModal", + component: InviteMemberModal, + parameters: { layout: "fullscreen" }, + args: { open: true, onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** Email + role; Send invite validates locally then closes (demo shell). */ +export const Open: Story = {}; + +export const Closed: Story = { args: { open: false } }; diff --git a/frontend/portal/src/components/users/InviteMemberModal.tsx b/frontend/portal/src/components/users/InviteMemberModal.tsx new file mode 100644 index 0000000000..78f0b7590e --- /dev/null +++ b/frontend/portal/src/components/users/InviteMemberModal.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { Button, FormField, Input, Modal, Select } from "@shared/components"; +import { type RoleId, ROLES } from "@portal/api/users"; +import "@portal/views/Users.css"; + +interface InviteMemberModalProps { + open: boolean; + onClose: () => void; +} + +const ROLE_SELECT_OPTIONS = ROLES.map((r) => ({ + value: r.id, + label: r.label, +})); + +/** Org Owner is reserved for transfer flows — invites default to Developer. */ +const DEFAULT_ROLE: RoleId = "developer"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Invite-by-email shell. Submitting validates locally then closes without + * sending — wiring the submit to the backend dispatches the invitation. + */ +export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { + const [email, setEmail] = useState(""); + const [role, setRole] = useState(DEFAULT_ROLE); + const [touched, setTouched] = useState(false); + + const emailValid = EMAIL_RE.test(email.trim()); + const error = + touched && !emailValid ? "Enter a valid email address" : undefined; + + function close() { + onClose(); + // Reset for the next open, after the close transition has finished. + setTimeout(() => { + setEmail(""); + setRole(DEFAULT_ROLE); + setTouched(false); + }, 200); + } + + function submit() { + setTouched(true); + if (!emailValid) return; + // TODO(backend): POST /v1/users/invitations { email, role } — send the + // invite, then close on success and refetch the members list. + close(); + } + + return ( + + + + + } + > +
+ + setEmail(e.target.value)} + onBlur={() => setTouched(true)} + /> + + +