mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(policies): backend-driven policy enforcement (frontend) (#6598)
## Summary Adds the **Policies** feature (proprietary, behind the `POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed tool pipeline on documents, docked in the right tool sidebar alongside Tools. ## Highlights - **Policy catalog** — 5 categories; **Security** is wired (redact PII + sanitize), the others are marked "Coming soon". - **Backend as source of truth** — policies persist via the Policies engine (`/api/v1/policies`), one policy per category, with a local cache + offline fallback. - **Auto-run** — enabled policies run on every uploaded file: dispatch → poll → import outputs into the workspace. - **Security redact config** — PII preset dropdown + custom word/regex entry + advanced options; tool params map to the backend endpoint fields. - **Activity feed** with retry on failures; **file badges** showing which policies ran on a file (sidebar + files page), tinted to the policy colour. - Reuses the **Watched Folders** engine for each policy's backing folder; policy-owned folders are filtered out of the Watched Folders UI. ## Notes - Gated by `POLICIES_ENABLED` (true in proprietary, false in core) — unreachable in the open-source build. - Frontend-only diff; depends on the backend Policies engine and the merged Watched Folders feature.
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
/* ============================ Policies ============================ */
|
||||
/* The Policies surface is docked in the right tool sidebar: a list section */
|
||||
/* above Tools, a detail takeover that replaces Tools when a policy is open, */
|
||||
/* and a collapsed-rail of policy icons. */
|
||||
/* */
|
||||
/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */
|
||||
/* (@shared/components). The bespoke .pol-* bits below are thin layout */
|
||||
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
|
||||
/* scale and colour to the SUI token set so it reads as one product. */
|
||||
|
||||
/* ---- List ---- */
|
||||
.pol-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Header row: optional leading control (sidebar collapse) + the SectionHeader,
|
||||
mirroring the back-button + title layout inside an open policy. */
|
||||
.pol-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.pol-list-head .sui-sectionhdr {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Small "what is a policy?" info button on the header. */
|
||||
/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */
|
||||
.pol-info-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.pol-info-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pol-list-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-1) var(--space-1_5);
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
/* A policy row: tinted icon tile + label + trailing status/CTA. */
|
||||
.pol-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-row:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
.pol-row:focus-visible {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.pol-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-row-trail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */
|
||||
.pol-row-setup {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
/* Trailing drill-in chevron on each policy row (after the status / CTA). */
|
||||
.pol-row-chevron {
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Retry button on a failed activity row. */
|
||||
/* Expandable error text in the activity feed — long backend errors are clamped
|
||||
and collapsed by default so they don't blow up the row. */
|
||||
.pol-activity-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.pol-activity-error__text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pol-activity-error__text--clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pol-activity-error__toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--color-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-activity-error__toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Locked "Coming soon" row — muted, not interactive. */
|
||||
.pol-row--soon {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.pol-row--soon:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.pol-row-soon {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* In-progress activity icon spins gently. */
|
||||
.pol-spin {
|
||||
animation: pol-spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes pol-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */
|
||||
.pol-tool-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pol-tool-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.pol-tool-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.pol-tool-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-right: auto;
|
||||
}
|
||||
.pol-tool-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---- Detail container (wizard / narrative / settings share this) ---- */
|
||||
.pol-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
|
||||
.pol-steps {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Scroll body ---- */
|
||||
.pol-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pol-desc {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-4);
|
||||
margin: 0;
|
||||
}
|
||||
.pol-section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
/* Bottom gap so the label doesn't hug its card/chips. */
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
|
||||
.pol-field {
|
||||
padding: 0.7rem 0.875rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.pol-field:not([data-first]) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-field-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.pol-field-chips-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pol-field-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
|
||||
/* ---- Sources ---- */
|
||||
.pol-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0.7rem 0.875rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.pol-source:not([data-first]) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Doc types ---- */
|
||||
.pol-link {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-doctypes-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0.875rem;
|
||||
}
|
||||
.pol-doctypes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0.875rem var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Summary ---- */
|
||||
.pol-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pol-summary-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */
|
||||
.pol-muted {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */
|
||||
.pol-rule-flow {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Meta / note ---- */
|
||||
.pol-meta-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.pol-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-muted);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Stats: one grouped card with three divided columns. ---- */
|
||||
.pol-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.pol-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-0_5);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
.pol-stat:not(:first-child) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-stat-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Footer (hosts SUI Buttons) ---- */
|
||||
.pol-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pol-footer-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- Detail takeover (fills the rail when a policy is open) ---- */
|
||||
.pol-takeover {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Collapsed rail policy icons ---- */
|
||||
.pol-crail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pol-crail-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-crail-btn:hover {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
.pol-crail-btn[data-status="active"] {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.pol-crail-btn[data-status="paused"] {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
.pol-crail-dot {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.1rem;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-toolbar, var(--color-surface));
|
||||
}
|
||||
.pol-crail-dot[data-status="active"] {
|
||||
background: var(--color-green);
|
||||
}
|
||||
.pol-crail-dot[data-status="paused"] {
|
||||
background: var(--color-amber);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
|
||||
import { PoliciesSection } from "@app/components/policies/PoliciesSidebar";
|
||||
import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
|
||||
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
|
||||
import "@app/components/policies/Policies.css";
|
||||
|
||||
/**
|
||||
* The Policies surface lives in the editor's right tool sidebar. These stories
|
||||
* render the three rich detail surfaces (narrative / setup wizard / settings)
|
||||
* inside a frame the width of the rail when a policy is open (25rem), so the
|
||||
* SUI composition can be reviewed in isolation — no app shell, login, or
|
||||
* backend required. Toggle the Storybook theme switcher to check dark mode.
|
||||
*/
|
||||
const RAIL_WIDTH = "25rem";
|
||||
|
||||
/** Frame that mimics the right rail's open width + surface so the panel reads true. */
|
||||
function RailFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: RAIL_WIDTH,
|
||||
height: "780px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ingestion = POLICY_CATEGORIES.find((c) => c.id === "ingestion")!;
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
|
||||
// Illustrative activity/stats for the static stories. In the app these are
|
||||
// derived live from the user's real uploaded files (see policyLiveData).
|
||||
const sampleActivity: PolicyActivityItem[] = [
|
||||
{
|
||||
doc: "MSA_Acme_2026.pdf",
|
||||
action: "1.2 MB • enforced on upload",
|
||||
time: "2h ago",
|
||||
status: "enforced",
|
||||
},
|
||||
{
|
||||
doc: "scan_002.pdf",
|
||||
action: "Low confidence • flagged for review",
|
||||
time: "Yesterday",
|
||||
status: "flagged",
|
||||
},
|
||||
];
|
||||
const sampleStats: PolicyStats = {
|
||||
enforced: 1284,
|
||||
dataProcessed: "3.2 GB",
|
||||
activeFor: "18d",
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Editor/Policies",
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
/** Configured policy, running — Enforces / Activity / Stats narrative. */
|
||||
export const DetailActive: Story = {
|
||||
render: () => (
|
||||
<RailFrame>
|
||||
<PolicyDetailPanel
|
||||
category={ingestion}
|
||||
config={POLICY_CONFIG.ingestion}
|
||||
status="active"
|
||||
steps={POLICY_CONFIG.ingestion.defaultOperations}
|
||||
activity={[
|
||||
{
|
||||
doc: "Q4_Report.pdf",
|
||||
action: "Enforcing…",
|
||||
time: "Just now",
|
||||
status: "processing",
|
||||
},
|
||||
...sampleActivity,
|
||||
]}
|
||||
stats={sampleStats}
|
||||
canConfigure
|
||||
canDelete
|
||||
onBack={noop}
|
||||
onEditSettings={noop}
|
||||
onTogglePause={noop}
|
||||
onDelete={noop}
|
||||
/>
|
||||
</RailFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** Configured policy, paused — amber accent + warning badge. */
|
||||
export const DetailPaused: Story = {
|
||||
render: () => (
|
||||
<RailFrame>
|
||||
<PolicyDetailPanel
|
||||
category={security}
|
||||
config={POLICY_CONFIG.security}
|
||||
status="paused"
|
||||
activity={sampleActivity}
|
||||
stats={sampleStats}
|
||||
canConfigure
|
||||
canDelete
|
||||
onBack={noop}
|
||||
onEditSettings={noop}
|
||||
onTogglePause={noop}
|
||||
onDelete={noop}
|
||||
/>
|
||||
</RailFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** Read-only view for a member without configure permission. */
|
||||
export const DetailManaged: Story = {
|
||||
render: () => (
|
||||
<RailFrame>
|
||||
<PolicyDetailPanel
|
||||
category={ingestion}
|
||||
config={POLICY_CONFIG.ingestion}
|
||||
status="active"
|
||||
activity={sampleActivity}
|
||||
stats={sampleStats}
|
||||
canConfigure={false}
|
||||
canDelete={false}
|
||||
onBack={noop}
|
||||
onEditSettings={noop}
|
||||
onTogglePause={noop}
|
||||
onDelete={noop}
|
||||
/>
|
||||
</RailFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** The policy list section (above Tools). */
|
||||
export const ListSection: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
width: RAIL_WIDTH,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
padding: "var(--space-2) 0",
|
||||
}}
|
||||
>
|
||||
<PoliciesSection />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// Note: the setup + edit wizard now embeds the Watch Folders automation builder
|
||||
// (its Workflow step), which needs the ToolWorkflow context — so the wizard is
|
||||
// exercised in-app, not via an isolated story here.
|
||||
@@ -0,0 +1,152 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// The shared Tooltip (used by the "what is a policy?" info button) pulls in
|
||||
// preferences/sidebar contexts we don't set up here — passthrough it.
|
||||
vi.mock("@app/components/shared/Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// The wizard's Workflow step embeds the Watch Folders automation builder, which
|
||||
// needs the ToolWorkflow context. Mock it: a stub that wires the save trigger to
|
||||
// hand back the seed automation, so the wizard's submit still completes.
|
||||
vi.mock("@app/components/policies/PolicyWorkflowStep", () => ({
|
||||
AutomationMode: { CREATE: "create", EDIT: "edit", SUGGESTED: "suggested" },
|
||||
PolicyWorkflowStep: (props: {
|
||||
automation: unknown;
|
||||
saveTriggerRef: { current: (() => void) | null };
|
||||
onComplete: (automation: unknown, toolRegistry: unknown) => void;
|
||||
}) => {
|
||||
props.saveTriggerRef.current = () => props.onComplete(props.automation, {});
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
// The backend is the source of truth, but these UI tests run offline: list
|
||||
// rejects (so the mount reconcile keeps the local cache), while save/delete
|
||||
// resolve so the enable flow completes.
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
listPolicies: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
savePolicy: vi.fn().mockImplementation(async (p: { id?: string }) => ({
|
||||
...p,
|
||||
id: p.id && p.id.length > 0 ? p.id : "be-test",
|
||||
})),
|
||||
getPolicy: vi.fn(),
|
||||
deletePolicy: vi.fn().mockResolvedValue(undefined),
|
||||
runStoredPolicy: vi.fn(),
|
||||
runPolicyPipeline: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
}));
|
||||
|
||||
// Enabling a policy creates its backing Watched Folders WatchedFolder (IndexedDB);
|
||||
// jsdom's crypto lacks randomUUID, which watchedFolderStorage uses for folder ids.
|
||||
if (typeof globalThis.crypto?.randomUUID !== "function") {
|
||||
const orig = globalThis.crypto;
|
||||
vi.stubGlobal("crypto", {
|
||||
getRandomValues: orig?.getRandomValues?.bind(orig),
|
||||
randomUUID: () =>
|
||||
`p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
|
||||
});
|
||||
}
|
||||
import {
|
||||
PoliciesSection,
|
||||
PolicyDetailTakeover,
|
||||
usePolicyDetailActive,
|
||||
} from "@app/components/policies/PoliciesSidebar";
|
||||
import { resetPolicySelection } from "@app/components/policies/policySelectionStore";
|
||||
|
||||
/**
|
||||
* Mirrors how RightSidebar swaps the policy list for the detail takeover: the
|
||||
* list shows above Tools when nothing is open, the takeover replaces it when a
|
||||
* policy is selected.
|
||||
*/
|
||||
function PoliciesHost() {
|
||||
const active = usePolicyDetailActive();
|
||||
return active ? <PolicyDetailTakeover /> : <PoliciesSection />;
|
||||
}
|
||||
|
||||
function renderHost() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<PoliciesHost />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Policies right-sidebar surface", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// Seed a configured Security policy (the only live category) in the local
|
||||
// cache. The backend list is mocked to reject (offline), so the mount
|
||||
// reconcile leaves it in place — giving the narrative tests a policy to open.
|
||||
localStorage.setItem(
|
||||
"stirling-policies-state",
|
||||
JSON.stringify({
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
resetPolicySelection();
|
||||
});
|
||||
|
||||
it("renders the policy list with every category", () => {
|
||||
renderHost();
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
for (const label of [
|
||||
"Ingestion",
|
||||
"Security",
|
||||
"Compliance",
|
||||
"Routing",
|
||||
"Retention",
|
||||
]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Security as active and the unbuilt categories as Coming soon", () => {
|
||||
renderHost();
|
||||
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
|
||||
// Ingestion, Compliance, Routing, Retention are locked for this release.
|
||||
expect(screen.getAllByText("Coming soon")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("does not open a Coming soon policy when its row is clicked", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Ingestion"));
|
||||
// The locked row isn't a button — we stay on the list, nothing opens.
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Enforces")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the narrative view when a live policy is clicked", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Enforces")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an honest empty activity feed when no files have been uploaded", async () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Recent Activity")).toBeInTheDocument();
|
||||
// Activity is derived from real uploads; with none, the empty state shows.
|
||||
expect(await screen.findByText("No activity yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the list via the back button", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Enforces")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Back"));
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Proprietary implementation of the right-sidebar Policies surface.
|
||||
*
|
||||
* Shadows the core stubs at {@code core/components/policies/PoliciesSidebar.tsx}
|
||||
* via the {@code @app/*} alias cascade. Three slots, all driven by the shared
|
||||
* {@link policySelectionStore} so they stay in sync:
|
||||
* • {@link PoliciesSection} — the collapsible policy list, rendered above the
|
||||
* Tools section in {@code RightSidebar} when no policy is open.
|
||||
* • {@link PolicyDetailTakeover} — the detail / wizard / settings view, which
|
||||
* replaces the Tools area when a policy is open.
|
||||
* • {@link PoliciesCollapsedButton} — the icon rail shown when the sidebar is
|
||||
* collapsed; clicking an icon selects the policy and expands the rail.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, type ReactNode } from "react";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
|
||||
import { getPolicyAutomation } from "@app/services/policyFolders";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import { runsToActivity, runsToStats } from "@app/services/policyLiveData";
|
||||
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
|
||||
import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
AutomationOperation,
|
||||
} from "@app/types/automation";
|
||||
import type { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { IconBadge } from "@shared/components/IconBadge";
|
||||
import {
|
||||
deriveRowStatus,
|
||||
STATUS_LABEL,
|
||||
ROW_ACCENT,
|
||||
} from "@app/components/policies/policyStatus";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
import { SectionHeader } from "@shared/components/SectionHeader";
|
||||
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
|
||||
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
|
||||
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
|
||||
import type { PolicyConfigResult } from "@app/types/policies";
|
||||
import {
|
||||
usePolicySelection,
|
||||
selectPolicy,
|
||||
setPolicyDetailView,
|
||||
closePolicy,
|
||||
} from "@app/components/policies/policySelectionStore";
|
||||
import "@app/components/policies/Policies.css";
|
||||
|
||||
/** localStorage key persisting the Policies section's expand/collapse state. */
|
||||
const POLICIES_COLLAPSED_KEY = "stirling-policies-section-collapsed";
|
||||
|
||||
/** Whether the right rail should host the Policies section. True in proprietary. */
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return POLICIES_ENABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy is open — i.e. its detail view should take over the rail in
|
||||
* place of the tool list. False when the feature is off or nothing is selected.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
const { selectedId } = usePolicySelection();
|
||||
return POLICIES_ENABLED && selectedId != null;
|
||||
}
|
||||
|
||||
/** The collapsible policy list, rendered above the Tools section. */
|
||||
export function PoliciesSection({
|
||||
leadingControl,
|
||||
}: {
|
||||
/** Optional control rendered to the left of the header (e.g. the sidebar
|
||||
* collapse button), mirroring the back-button + title in a policy. */
|
||||
leadingControl?: ReactNode;
|
||||
} = {}) {
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
// Persist the expand/collapse state across refreshes.
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(POLICIES_COLLAPSED_KEY) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const toggleExpanded = () =>
|
||||
setExpanded((open) => {
|
||||
const next = !open;
|
||||
try {
|
||||
localStorage.setItem(POLICIES_COLLAPSED_KEY, next ? "0" : "1");
|
||||
} catch {
|
||||
// Best-effort; ignore quota/availability failures.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
|
||||
// The header tally counts every CONFIGURED policy (active + paused), not just
|
||||
// the active ones.
|
||||
const configuredCount = categories.filter(
|
||||
(c) => pol.policies[c.id]?.configured,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="pol-list">
|
||||
<div className="pol-list-head">
|
||||
{leadingControl}
|
||||
<SectionHeader
|
||||
title="Policies"
|
||||
count={`${configuredCount} active`}
|
||||
collapsible
|
||||
expanded={expanded}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
<AppTooltip
|
||||
content="A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
|
||||
sidebarTooltip
|
||||
pinOnClick
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-info-btn"
|
||||
aria-label="What is a policy?"
|
||||
>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--icon-files-color)" }}
|
||||
/>
|
||||
</button>
|
||||
</AppTooltip>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="pol-list-rows">
|
||||
{categories.map((cat) => {
|
||||
if (cat.comingSoon) {
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="pol-row pol-row--soon"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">{cat.label}</span>
|
||||
<span className="pol-row-trail">
|
||||
<span className="pol-row-soon">Coming soon</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
className="pol-row"
|
||||
onClick={() => selectPolicy(cat.id)}
|
||||
>
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">{cat.label}</span>
|
||||
<span className="pol-row-trail">
|
||||
{status === "setup" ? (
|
||||
<span className="pol-row-setup">Set up</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
tone={status === "active" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className="pol-row-chevron"
|
||||
sx={{ fontSize: "1rem" }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-policy view — narrative detail, setup wizard, or edit-settings —
|
||||
* which replaces the Tools area while a policy is selected.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
const pol = usePolicies();
|
||||
const { categories, configs, sources, docTypes } = usePolicyCatalog();
|
||||
const { selectedId, detailView } = usePolicySelection();
|
||||
|
||||
// The configured policy's backing folder + automation (its real, editable
|
||||
// pipeline). `reloadKey` bumps after the edit modal saves so the detail
|
||||
// reflects the new steps. Falls back to the preset's rules when unconfigured.
|
||||
const folderId = selectedId ? pol.policies[selectedId]?.folderId : undefined;
|
||||
const [steps, setSteps] = useState<AutomationOperation[]>([]);
|
||||
const [backingFolder, setBackingFolder] = useState<WatchedFolder | null>(
|
||||
null,
|
||||
);
|
||||
const [backingAutomation, setBackingAutomation] =
|
||||
useState<AutomationConfig | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!folderId) {
|
||||
setSteps([]);
|
||||
setBackingFolder(null);
|
||||
setBackingAutomation(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const [folder, automation] = await Promise.all([
|
||||
watchedFolderStorage.getFolder(folderId),
|
||||
getPolicyAutomation(folderId),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setBackingFolder(folder);
|
||||
setBackingAutomation(automation);
|
||||
setSteps(automation?.operations ?? []);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [folderId, reloadKey]);
|
||||
|
||||
// Activity/stats come from the real backend runs the auto-run controller fires
|
||||
// on every upload (policyRunStore), filtered to this policy's category. The
|
||||
// store is reactive, so the feed updates live as runs progress — no polling
|
||||
// here (the controller does the run-status polling).
|
||||
const allRuns = usePolicyRuns();
|
||||
const categoryRuns = useMemo(
|
||||
() => allRuns.filter((r) => r.categoryId === selectedId),
|
||||
[allRuns, selectedId],
|
||||
);
|
||||
|
||||
if (!POLICIES_ENABLED || selectedId == null) return null;
|
||||
|
||||
const category = categories.find((c) => c.id === selectedId);
|
||||
const state = pol.policies[selectedId];
|
||||
const config = configs[selectedId];
|
||||
if (!category || !state || !config) return null;
|
||||
// Coming-soon categories can't be opened (the list row is locked anyway).
|
||||
if (category.comingSoon) return null;
|
||||
|
||||
const status = deriveRowStatus(state);
|
||||
|
||||
const onSetupClassification = () => {
|
||||
const classifier = categories.find((c) => c.providesClassification);
|
||||
if (classifier) selectPolicy(classifier.id);
|
||||
};
|
||||
|
||||
// Preset (tool-chain) policies configure via the wizard's locked tool-config
|
||||
// step instead of the add/remove builder; the wizard fires onCommitConfig for
|
||||
// them (and onComplete for builder-based categories). One commit path serves
|
||||
// both first-time configure and edits.
|
||||
const commitConfig = (result: PolicyConfigResult) =>
|
||||
pol.commitPolicyConfig(selectedId, result).then(() => {
|
||||
setReloadKey((k) => k + 1);
|
||||
setPolicyDetailView("detail");
|
||||
});
|
||||
|
||||
// Setup: the shared wizard in create mode.
|
||||
if (!state.configured) {
|
||||
return (
|
||||
<PolicySetupWizard
|
||||
key={selectedId}
|
||||
category={category}
|
||||
config={config}
|
||||
initial={state}
|
||||
sources={sources}
|
||||
docTypes={docTypes}
|
||||
canConfigure={pol.canConfigure}
|
||||
// No standalone classification policy exists yet to enable doc-type
|
||||
// narrowing, so it stays off for this release.
|
||||
classificationEnabled={false}
|
||||
mode="create"
|
||||
onCancel={() => closePolicy()}
|
||||
onComplete={(result) =>
|
||||
pol
|
||||
.enablePolicy(selectedId, result)
|
||||
.then(() => setPolicyDetailView("detail"))
|
||||
}
|
||||
onCommitConfig={commitConfig}
|
||||
onSetupClassification={onSetupClassification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit: the same wizard in edit mode, pre-filled — so editing has the settings
|
||||
// steps (not just the workflow). Wait for the backing automation to load so
|
||||
// the workflow step edits the real pipeline.
|
||||
if (detailView === "settings" && pol.canConfigure) {
|
||||
if (!backingAutomation) {
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<div className="pol-scroll">
|
||||
<p className="pol-desc">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PolicySetupWizard
|
||||
key={`edit-${selectedId}`}
|
||||
category={category}
|
||||
config={config}
|
||||
initial={state}
|
||||
sources={sources}
|
||||
docTypes={docTypes}
|
||||
canConfigure={pol.canConfigure}
|
||||
classificationEnabled={false}
|
||||
mode="edit"
|
||||
existingAutomation={backingAutomation}
|
||||
initialFolder={backingFolder ?? undefined}
|
||||
onCancel={() => setPolicyDetailView("detail")}
|
||||
onComplete={(result) =>
|
||||
pol.savePolicyConfig(selectedId, result).then(() => {
|
||||
setReloadKey((k) => k + 1);
|
||||
setPolicyDetailView("detail");
|
||||
})
|
||||
}
|
||||
onCommitConfig={commitConfig}
|
||||
onSetupClassification={onSetupClassification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PolicyDetailPanel
|
||||
category={category}
|
||||
config={config}
|
||||
status={status}
|
||||
steps={steps}
|
||||
activity={runsToActivity(categoryRuns)}
|
||||
stats={runsToStats(categoryRuns, backingFolder?.createdAt)}
|
||||
canConfigure={pol.canConfigure}
|
||||
canDelete={!state.isDefault}
|
||||
onBack={() => closePolicy()}
|
||||
onEditSettings={() => {
|
||||
// Seeded/active policies may have no backing folder yet — create one
|
||||
// from the preset so there's a workflow to edit, then open settings.
|
||||
void pol
|
||||
.ensurePolicyFolder(selectedId)
|
||||
.then(() => setPolicyDetailView("settings"));
|
||||
}}
|
||||
onTogglePause={() =>
|
||||
status === "paused"
|
||||
? pol.resumePolicy(selectedId)
|
||||
: pol.pausePolicy(selectedId)
|
||||
}
|
||||
onDelete={() => setConfirmingDelete(true)}
|
||||
onRetry={(item) => {
|
||||
if (item.fileId && state.backendId) {
|
||||
void runPolicyOnFile(
|
||||
selectedId,
|
||||
state.backendId,
|
||||
item.fileId as FileId,
|
||||
item.doc,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{confirmingDelete && (
|
||||
<PolicyDeleteConfirmModal
|
||||
opened
|
||||
label={category.label}
|
||||
onCancel={() => setConfirmingDelete(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmingDelete(false);
|
||||
closePolicy();
|
||||
void pol.deletePolicy(selectedId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-rail policy icons. Each tints blue when active and carries a small
|
||||
* status dot (green active / amber paused). Clicking selects the policy and
|
||||
* expands the rail.
|
||||
*/
|
||||
export function PoliciesCollapsedButton({
|
||||
onExpand,
|
||||
}: {
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pol-crail">
|
||||
{categories
|
||||
.filter((cat) => !cat.comingSoon)
|
||||
.map((cat) => {
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const suffix =
|
||||
status === "active"
|
||||
? " (Active)"
|
||||
: status === "paused"
|
||||
? " (Paused)"
|
||||
: "";
|
||||
return (
|
||||
<AppTooltip
|
||||
key={cat.id}
|
||||
content={`${cat.label}${suffix}`}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-crail-btn"
|
||||
data-status={status}
|
||||
aria-label={`${cat.label} policy — ${STATUS_LABEL[status]}`}
|
||||
onClick={() => {
|
||||
selectPolicy(cat.id);
|
||||
onExpand();
|
||||
}}
|
||||
>
|
||||
{cat.icon}
|
||||
{(status === "active" || status === "paused") && (
|
||||
<span className="pol-crail-dot" data-status={status} />
|
||||
)}
|
||||
</button>
|
||||
</AppTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
|
||||
/**
|
||||
* Headless controller that drives policy auto-run (enforce every enabled policy
|
||||
* on every uploaded file). Mounted once wherever the editor is open so runs fire
|
||||
* regardless of whether the policy panel is visible. Renders nothing.
|
||||
*/
|
||||
export function PolicyAutoRunController() {
|
||||
usePolicyAutoRun();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Modal, Text, Button, Stack, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PolicyDeleteConfirmModalProps {
|
||||
opened: boolean;
|
||||
/** The policy's display label (the category name). */
|
||||
label: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Confirm before deleting a policy — removing it discards its backing workflow. */
|
||||
export function PolicyDeleteConfirmModal({
|
||||
opened,
|
||||
label,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PolicyDeleteConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("policies.deleteConfirmTitle", `Delete ${label} policy?`)}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"policies.deleteConfirmBody",
|
||||
"This removes the policy and its workflow. Documents already processed are not affected.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button color="red" size="sm" onClick={onConfirm}>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState } from "react";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { Card } from "@shared/components/Card";
|
||||
import { ChipFlow } from "@shared/components/ChipFlow";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
import { EmptyState } from "@shared/components/EmptyState";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { Banner } from "@shared/components/Banner";
|
||||
import { ListRow } from "@shared/components/ListRow";
|
||||
import type {
|
||||
PolicyActivityItem,
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyRowStatus,
|
||||
PolicyStats,
|
||||
} from "@app/types/policies";
|
||||
import type { AutomationOperation } from "@app/types/automation";
|
||||
|
||||
interface PolicyDetailPanelProps {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
/** Derived display status. */
|
||||
status: PolicyRowStatus;
|
||||
/**
|
||||
* The policy's real configured steps (from its backing automation). When
|
||||
* present these drive the Enforces flow; otherwise the preset's decorative
|
||||
* `rules` are shown (e.g. before configuration).
|
||||
*/
|
||||
steps?: AutomationOperation[];
|
||||
/** Activity feed derived from the user's files; empty until files exist. */
|
||||
activity?: PolicyActivityItem[];
|
||||
/** Summary stats derived from the user's files. */
|
||||
stats?: PolicyStats;
|
||||
canConfigure: boolean;
|
||||
/** Default (built-in) policies aren't deletable, so the Delete action hides. */
|
||||
canDelete: boolean;
|
||||
onBack: () => void;
|
||||
onEditSettings: () => void;
|
||||
onTogglePause: () => void;
|
||||
onDelete: () => void;
|
||||
/** Re-run a failed activity item's policy on its file. */
|
||||
onRetry?: (item: PolicyActivityItem) => void;
|
||||
}
|
||||
|
||||
/** "addWatermark" → "Add Watermark" — a light humanisation of op ids for display. */
|
||||
function humanizeOperation(op: string): string {
|
||||
return op
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed run's error in the activity feed. Backend errors can be long (or
|
||||
* multi-line stack traces) and would otherwise blow up the row, so anything
|
||||
* lengthy is clamped and collapsed by default with a Show more/less toggle.
|
||||
* Short messages (e.g. "Enforcement failed") render plainly with no toggle.
|
||||
*/
|
||||
function ActivityError({ message }: { message: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const needsToggle = message.length > 80 || message.includes("\n");
|
||||
if (!needsToggle) return <>{message}</>;
|
||||
return (
|
||||
<span className="pol-activity-error">
|
||||
<span
|
||||
className={`pol-activity-error__text${expanded ? "" : " pol-activity-error__text--clamped"}`}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-activity-error__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrative view for a configured policy (Enforces / Activity / Stats). */
|
||||
export function PolicyDetailPanel({
|
||||
category,
|
||||
config,
|
||||
status,
|
||||
steps,
|
||||
activity,
|
||||
stats,
|
||||
canConfigure,
|
||||
canDelete,
|
||||
onBack,
|
||||
onEditSettings,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
onRetry,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const isPaused = status === "paused";
|
||||
// Real configured steps drive the flow; fall back to the preset's rule labels.
|
||||
const enforceItems =
|
||||
steps && steps.length > 0
|
||||
? steps.map((s) => humanizeOperation(s.operation))
|
||||
: config.rules;
|
||||
// Activity + stats are derived from the user's real files; until they load (or
|
||||
// if none exist) show an honest empty feed / zeroed stats.
|
||||
const activityItems = activity ?? [];
|
||||
const statValues = stats ?? {
|
||||
enforced: 0,
|
||||
dataProcessed: "0 B",
|
||||
activeFor: "—",
|
||||
};
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
title={category.label}
|
||||
onBack={onBack}
|
||||
actions={
|
||||
<StatusBadge
|
||||
tone={isPaused ? "warning" : "success"}
|
||||
showDot
|
||||
pulse={!isPaused}
|
||||
>
|
||||
{isPaused ? "Paused" : "Active"}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pol-scroll">
|
||||
{/* Enforces */}
|
||||
<div>
|
||||
<p className="pol-section-label">Enforces</p>
|
||||
<Card padding="default">
|
||||
<div className="pol-rule-flow">
|
||||
<ChipFlow items={enforceItems} separator="arrow" />
|
||||
</div>
|
||||
<div className="pol-meta-row">
|
||||
<span className="pol-meta-item">
|
||||
<PublicIcon sx={{ fontSize: "0.8rem" }} />
|
||||
{config.scopeLabel}
|
||||
</span>
|
||||
<span className="pol-meta-item">
|
||||
<ScheduleIcon sx={{ fontSize: "0.8rem" }} />
|
||||
On every upload
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-note">
|
||||
<HistoryIcon sx={{ fontSize: "0.8rem" }} />
|
||||
Originals stay untouched • Enforced version saved alongside
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<p className="pol-section-label">Recent Activity</p>
|
||||
{activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activityItems.map((item, i) => (
|
||||
<ListRow
|
||||
key={item.runId ?? `${item.doc}-${item.time}`}
|
||||
divider={i > 0}
|
||||
leadingTone={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
leading={
|
||||
item.status === "flagged" ? (
|
||||
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
|
||||
) : item.status === "processing" ? (
|
||||
<AutorenewIcon
|
||||
className="pol-spin"
|
||||
sx={{ fontSize: "0.85rem" }}
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
|
||||
)
|
||||
}
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
}
|
||||
meta={item.time}
|
||||
trailing={
|
||||
item.status === "flagged" && onRetry ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title="No activity yet"
|
||||
description="Documents will appear here once this policy runs."
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats — one grouped card with divided columns, intentionally
|
||||
unlabelled for a quiet summary footer. */}
|
||||
<Card padding="none">
|
||||
<div className="pol-stats">
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">
|
||||
{statValues.enforced.toLocaleString()}
|
||||
</span>
|
||||
<span className="pol-stat-label">Docs enforced</span>
|
||||
</div>
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">{statValues.dataProcessed}</span>
|
||||
<span className="pol-stat-label">Data processed</span>
|
||||
</div>
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">{statValues.activeFor}</span>
|
||||
<span className="pol-stat-label">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!canConfigure && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockIcon sx={{ fontSize: "1rem" }} />}
|
||||
description="Managed by your organization. Contact an admin to change settings."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canConfigure && (
|
||||
<div className={`pol-footer${canDelete ? "" : " pol-footer-end"}`}>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
accent="red"
|
||||
size="sm"
|
||||
leadingIcon={<DeleteOutlineIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={onDelete}
|
||||
style={{ marginRight: "auto" }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onTogglePause}>
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
<Button variant="gradient" size="sm" onClick={onEditSettings}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
|
||||
import { Select } from "@shared/components/Select";
|
||||
import { Input } from "@shared/components/Input";
|
||||
import { Chip } from "@shared/components/Chip";
|
||||
import { SettingsRow } from "@shared/components/SettingsRow";
|
||||
import type { PolicyField } from "@app/types/policies";
|
||||
|
||||
interface PolicyFieldRowProps {
|
||||
field: PolicyField;
|
||||
/** Effective current value (override or definition default). */
|
||||
value: boolean | string | string[];
|
||||
onChange: (value: boolean | string | string[]) => void;
|
||||
/** First row in a group omits the top divider. */
|
||||
first?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one policy setting: toggle, select, multi-select chips, or text.
|
||||
* Controlled — the parent owns the value. Uses SUI controls (ToggleSwitch /
|
||||
* Select / Input / Chip) so it matches the rest of the policy surface.
|
||||
*/
|
||||
export function PolicyFieldRow({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
first,
|
||||
}: PolicyFieldRowProps) {
|
||||
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 (
|
||||
<div className="pol-field" data-first={first || undefined}>
|
||||
<div className="pol-field-chips-head">
|
||||
<span className="pol-field-label">{field.label}</span>
|
||||
<span className="pol-field-count">{selected.length} selected</span>
|
||||
</div>
|
||||
<div className="pol-field-chips">
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<Chip
|
||||
key={opt}
|
||||
tone={selected.includes(opt) ? "blue" : "neutral"}
|
||||
size="sm"
|
||||
onClick={() => toggle(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const control =
|
||||
field.type === "toggle" ? (
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={Boolean(value)}
|
||||
onChange={(checked) => onChange(checked)}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
inputSize="sm"
|
||||
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pol-field" data-first={first || undefined}>
|
||||
<SettingsRow label={field.label} control={control} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MultiSelect } from "@mantine/core";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
|
||||
/** The set of preset regexes — used to separate preset words from custom ones. */
|
||||
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
|
||||
const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
|
||||
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
|
||||
|
||||
interface PolicyPiiFieldProps {
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The redact step's PII preset picker — a dropdown of common PII types that
|
||||
* writes the matching regexes into `wordsToRedact` (automatic + regex). It only
|
||||
* manages the preset entries; any custom patterns the user added separately are
|
||||
* left untouched, so this sits alongside the custom-entry field rather than
|
||||
* owning the whole list.
|
||||
*/
|
||||
export function PolicyPiiField({
|
||||
parameters,
|
||||
onChange,
|
||||
disabled,
|
||||
}: PolicyPiiFieldProps) {
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
const selected = words
|
||||
.map((w) => VALUE_BY_PATTERN.get(w))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
const presetPatterns = values
|
||||
.map((v) => PATTERN_BY_VALUE.get(v))
|
||||
.filter((p): p is string => Boolean(p));
|
||||
// Keep the user's custom patterns; only swap the preset selection.
|
||||
const customWords = words.filter((w) => !PRESET_PATTERNS.has(w));
|
||||
onChange({
|
||||
...parameters,
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
wordsToRedact: [...presetPatterns, ...customWords],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
size="sm"
|
||||
label="PII to redact"
|
||||
placeholder="Select PII types"
|
||||
data={PII_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
clearable
|
||||
checkIconPosition="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Divider,
|
||||
ColorInput,
|
||||
NumberInput,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
import {
|
||||
PolicyPiiField,
|
||||
PRESET_PATTERNS,
|
||||
} from "@app/components/policies/PolicyPiiField";
|
||||
|
||||
interface PolicyRedactConfigProps {
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact configuration for a policy: a PII preset dropdown plus a separate field
|
||||
* for the user's own words / regexes, then the advanced redact options. The two
|
||||
* lists are kept disjoint — the dropdown owns the preset patterns, the custom
|
||||
* field owns everything else — so selecting presets and typing custom patterns
|
||||
* don't clobber each other. Regex matching is always on (a plain word is a
|
||||
* literal regex), so the Use-Regex toggle is omitted. Mode stays automatic since
|
||||
* policies run headless and manual redaction needs the canvas.
|
||||
*/
|
||||
export function PolicyRedactConfig({
|
||||
parameters,
|
||||
onChange,
|
||||
disabled,
|
||||
}: PolicyRedactConfigProps) {
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
// Split the stored list: presets are driven by the dropdown, the rest by the
|
||||
// custom field below. Each editor only ever rewrites its own half.
|
||||
const presetWords = words.filter((w) => PRESET_PATTERNS.has(w));
|
||||
const customWords = words.filter((w) => !PRESET_PATTERNS.has(w));
|
||||
|
||||
const redactColor =
|
||||
typeof parameters.redactColor === "string"
|
||||
? parameters.redactColor
|
||||
: "#000000";
|
||||
const customPadding =
|
||||
typeof parameters.customPadding === "number"
|
||||
? parameters.customPadding
|
||||
: 0.1;
|
||||
const wholeWordSearch = parameters.wholeWordSearch === true;
|
||||
const convertPDFToImage = parameters.convertPDFToImage !== false; // default on
|
||||
|
||||
// Regex is always on for policies; heal any older/default-false value once on
|
||||
// mount (empty deps — we only need to normalise the persisted flag).
|
||||
useEffect(() => {
|
||||
if (parameters.useRegex !== true) {
|
||||
onChange({ ...parameters, useRegex: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every edit keeps mode automatic + regex on (the two policy invariants).
|
||||
const patch = (next: Record<string, unknown>) =>
|
||||
onChange({ ...parameters, mode: "automatic", useRegex: true, ...next });
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<PolicyPiiField
|
||||
parameters={parameters}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={customWords}
|
||||
onWordsChange={(next) =>
|
||||
patch({ wordsToRedact: [...presetWords, ...next] })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ColorInput
|
||||
label="Box colour"
|
||||
value={redactColor}
|
||||
onChange={(value) => patch({ redactColor: value })}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
format="hex"
|
||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Custom extra padding"
|
||||
value={customPadding}
|
||||
onChange={(value) =>
|
||||
patch({ customPadding: typeof value === "number" ? value : 0.1 })
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
placeholder="0.1"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Whole word search"
|
||||
checked={wholeWordSearch}
|
||||
onChange={(e) => patch({ wholeWordSearch: e.currentTarget.checked })}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Convert PDF to PDF-image (removes text behind the box)"
|
||||
checked={convertPDFToImage}
|
||||
onChange={(e) => patch({ convertPDFToImage: e.currentTarget.checked })}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import { Card } from "@shared/components/Card";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { ChipFlow } from "@shared/components/ChipFlow";
|
||||
import { DataRow } from "@shared/components/DataRow";
|
||||
import { Input } from "@shared/components/Input";
|
||||
import { Select } from "@shared/components/Select";
|
||||
import { SettingsRow } from "@shared/components/SettingsRow";
|
||||
import { FormField } from "@shared/components/FormField";
|
||||
import { Checkbox } from "@shared/components/Checkbox";
|
||||
import { Banner } from "@shared/components/Banner";
|
||||
import { EmptyState } from "@shared/components/EmptyState";
|
||||
import { StepIndicator } from "@shared/components/StepIndicator";
|
||||
import { IconBadge } from "@shared/components/IconBadge";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyConfigResult,
|
||||
PolicySource,
|
||||
PolicyState,
|
||||
PolicyWizardResult,
|
||||
} from "@app/types/policies";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
AutomationOperation,
|
||||
} from "@app/types/automation";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { buildPipelineDefinition } from "@app/services/policyPipeline";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { PolicyFieldRow } from "@app/components/policies/PolicyFieldRow";
|
||||
import { resolveFieldValues } from "@app/components/policies/policyValues";
|
||||
import {
|
||||
PolicyWorkflowStep,
|
||||
AutomationMode,
|
||||
} from "@app/components/policies/PolicyWorkflowStep";
|
||||
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
|
||||
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
|
||||
|
||||
// Sources are always "editor" for this release, so the Sources step is dropped
|
||||
// from the flow (its panel code is kept below for when other sources return).
|
||||
const SOURCES_IN_FLOW = false;
|
||||
const TOTAL_STEPS = SOURCES_IN_FLOW ? 4 : 3;
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
initial: PolicyState;
|
||||
/** Sources a policy can run over (catalog-supplied). */
|
||||
sources: PolicySource[];
|
||||
/** Document types scope can be narrowed to (catalog-supplied). */
|
||||
docTypes: string[];
|
||||
canConfigure: boolean;
|
||||
/** Whether the Classification (ingestion) policy is active — gates doc-type narrowing. */
|
||||
classificationEnabled: boolean;
|
||||
/** "create" seeds the workflow from the preset; "edit" loads the backing automation. */
|
||||
mode?: "create" | "edit";
|
||||
/** The backing automation to edit (edit mode). */
|
||||
existingAutomation?: AutomationConfig;
|
||||
/** The backing folder, to pre-fill output + retry settings (edit mode). */
|
||||
initialFolder?: WatchedFolder;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Fires on submit with the saved workflow + collected settings. May be async;
|
||||
* if the returned promise rejects, the wizard re-enables submit and surfaces
|
||||
* the failure rather than hanging on a permanently-disabled button.
|
||||
*/
|
||||
onComplete: (result: PolicyWizardResult) => void | Promise<void>;
|
||||
/**
|
||||
* For preset (tool-chain) policies whose Workflow step is the locked tool
|
||||
* config: fires instead of `onComplete`, carrying the configured tools as
|
||||
* operations + mapped pipeline steps. When absent the wizard uses the
|
||||
* add/remove builder + `onComplete`.
|
||||
*/
|
||||
onCommitConfig?: (result: PolicyConfigResult) => void | Promise<void>;
|
||||
onSetupClassification: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared policy wizard, used for both setup and edit. Four steps:
|
||||
* Workflow (the tool pipeline, reusing the Watch Folders builder) → Settings
|
||||
* (the policy fields) → Sources → Review. The workflow builder is kept mounted
|
||||
* across steps so the final action can trigger its save.
|
||||
*/
|
||||
export function PolicySetupWizard({
|
||||
category,
|
||||
config,
|
||||
initial,
|
||||
sources: sourceDefs,
|
||||
docTypes,
|
||||
canConfigure,
|
||||
classificationEnabled,
|
||||
mode = "create",
|
||||
existingAutomation,
|
||||
initialFolder,
|
||||
onCancel,
|
||||
onComplete,
|
||||
onCommitConfig,
|
||||
onSetupClassification,
|
||||
}: PolicySetupWizardProps) {
|
||||
const isEdit = mode === "edit";
|
||||
// Preset (tool-chain) policies render the locked tool config as their Workflow
|
||||
// step instead of the add/remove builder.
|
||||
const toolChain = getPolicyToolChain(category.id);
|
||||
const { user } = useAuth();
|
||||
const [step, setStep] = useState(1);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(config, initial),
|
||||
);
|
||||
const [sources, setSources] = useState<string[]>(
|
||||
initial.sources.length ? initial.sources : ["editor"],
|
||||
);
|
||||
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
|
||||
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
|
||||
// Default flagged-document reviewer to the signed-in user.
|
||||
const [reviewerEmail, setReviewerEmail] = useState(
|
||||
initial.reviewerEmail || user?.email || "",
|
||||
);
|
||||
// Output + retry settings — the real, working folder settings (the engine
|
||||
// applies them). Pre-filled from the backing folder in edit mode.
|
||||
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
||||
initialFolder?.outputMode ?? "new_file",
|
||||
);
|
||||
const [outputName, setOutputName] = useState(initialFolder?.outputName ?? "");
|
||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||
"prefix" | "suffix" | "auto-number"
|
||||
>(initialFolder?.outputNamePosition ?? "prefix");
|
||||
const [maxRetries, setMaxRetries] = useState(initialFolder?.maxRetries ?? 3);
|
||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
||||
initialFolder?.retryDelayMinutes ?? 5,
|
||||
);
|
||||
const workflowSave = useRef<(() => void) | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Seed the workflow builder: the backing automation in edit, else a synthetic
|
||||
// config carrying the category preset's operations (created on save).
|
||||
const seedAutomation = useMemo<AutomationConfig>(
|
||||
() =>
|
||||
existingAutomation ?? {
|
||||
id: "",
|
||||
name: `${category.label} Policy`,
|
||||
description: `${category.label} policy workflow`,
|
||||
icon: "WorkIcon",
|
||||
operations: config.defaultOperations,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
[existingAutomation, category.label, config.defaultOperations],
|
||||
);
|
||||
|
||||
if (!canConfigure) {
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
|
||||
onBack={onCancel}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<EmptyState
|
||||
title="Managed by your organization"
|
||||
description="Contact an admin to change this policy."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const back = () =>
|
||||
step > 1 ? setStep((s) => Math.max(1, s - 1)) : onCancel();
|
||||
|
||||
const toggleSource = (id: string) =>
|
||||
setSources((prev) =>
|
||||
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
|
||||
);
|
||||
|
||||
// Once the builder persists the workflow, map its operations to backend
|
||||
// endpoint paths (the registry only lives in the Workflow step) and hand the
|
||||
// automation + built steps + settings to the host (which closes the wizard on
|
||||
// success). If the host's async save rejects, recover so the submit button
|
||||
// doesn't stay disabled forever.
|
||||
const handleWorkflowSaved = (
|
||||
automation: AutomationConfig,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
) => {
|
||||
const { definition, unresolved } = buildPipelineDefinition(
|
||||
automation,
|
||||
toolRegistry,
|
||||
);
|
||||
Promise.resolve(
|
||||
onComplete({
|
||||
automation,
|
||||
fieldValues,
|
||||
sources,
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
reviewerEmail,
|
||||
folder: {
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
},
|
||||
pipelineSteps: definition.steps,
|
||||
unresolvedOps: unresolved,
|
||||
}),
|
||||
).catch(() => {
|
||||
setSubmitting(false);
|
||||
setSaveError("Couldn't save the policy. Please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
// Tool-chain policies: the locked config step emits its enabled tools as
|
||||
// operations + mapped steps; hand them to the host's commit path.
|
||||
const handleToolConfigSaved = (
|
||||
operations: AutomationOperation[],
|
||||
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
|
||||
unresolvedOps: string[],
|
||||
) => {
|
||||
Promise.resolve(
|
||||
onCommitConfig?.({
|
||||
operations,
|
||||
pipelineSteps,
|
||||
unresolvedOps,
|
||||
fieldValues,
|
||||
sources,
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
reviewerEmail,
|
||||
folder: {
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
},
|
||||
}),
|
||||
).catch(() => {
|
||||
setSubmitting(false);
|
||||
setSaveError("Couldn't save the policy. Please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
// Final submit: guard against double-submit (the step stays mounted, so a
|
||||
// second click would persist twice), then trigger the step's save.
|
||||
const submit = () => {
|
||||
if (submitting) return;
|
||||
setSaveError(null);
|
||||
setSubmitting(true);
|
||||
workflowSave.current?.();
|
||||
};
|
||||
|
||||
// The builder couldn't save (e.g. no configured tools) — surface it and send
|
||||
// the user back to the Workflow step to fix it.
|
||||
const handleSaveFailed = () => {
|
||||
setSubmitting(false);
|
||||
setSaveError("Add at least one configured tool to the workflow first.");
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
title={`${isEdit ? "Edit" : "Set up"} ${category.label} Policy`}
|
||||
subtitle={`Step ${step} of ${TOTAL_STEPS}`}
|
||||
onBack={back}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
leadingIcon={<CloseIcon sx={{ fontSize: "1.1rem" }} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pol-steps">
|
||||
<StepIndicator total={TOTAL_STEPS} current={step} />
|
||||
</div>
|
||||
|
||||
<div className="pol-scroll">
|
||||
{saveError && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={saveError}
|
||||
/>
|
||||
)}
|
||||
{/* Step 1 — Workflow. Kept mounted (hidden on other steps) so the final
|
||||
submit can trigger its save. Preset (tool-chain) policies show the
|
||||
locked, per-tool config; the rest show the add/remove builder. */}
|
||||
<div style={{ display: step === 1 ? undefined : "none" }}>
|
||||
{toolChain ? (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
Configure the tools this policy runs on each document.
|
||||
</p>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
initialOperations={
|
||||
existingAutomation?.operations ?? config.defaultOperations
|
||||
}
|
||||
presetOperations={config.defaultOperations}
|
||||
categoryLabel={category.label}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleToolConfigSaved}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
Build the sequence of tools this policy runs on each document.
|
||||
</p>
|
||||
<PolicyWorkflowStep
|
||||
automation={seedAutomation}
|
||||
mode={isEdit ? AutomationMode.EDIT : AutomationMode.SUGGESTED}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleWorkflowSaved}
|
||||
onSaveFailed={handleSaveFailed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<p className="pol-desc">{category.desc}</p>
|
||||
<Card padding="none">
|
||||
{config.fields.map((f, i) => (
|
||||
<PolicyFieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={fieldValues[f.key]}
|
||||
first={i === 0}
|
||||
onChange={(v) =>
|
||||
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* Real, working output + retry settings (applied by the engine). */}
|
||||
<p className="pol-section-label">Output & retries</p>
|
||||
<Card padding="none">
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label="Output"
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputMode}
|
||||
onChange={(e) =>
|
||||
setOutputMode(
|
||||
e.target.value as "new_file" | "new_version",
|
||||
)
|
||||
}
|
||||
aria-label="Output mode"
|
||||
options={[
|
||||
{ value: "new_file", label: "New file" },
|
||||
{ value: "new_version", label: "New version" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Output name"
|
||||
control={
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
placeholder="optional"
|
||||
aria-label="Output name"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Name position"
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputNamePosition}
|
||||
onChange={(e) =>
|
||||
setOutputNamePosition(
|
||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
aria-label="Name position"
|
||||
options={[
|
||||
{ value: "prefix", label: "Prefix" },
|
||||
{ value: "suffix", label: "Suffix" },
|
||||
{ value: "auto-number", label: "Auto-number" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Max retries"
|
||||
control={
|
||||
<Input
|
||||
type="number"
|
||||
inputSize="sm"
|
||||
value={String(maxRetries)}
|
||||
onChange={(e) =>
|
||||
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
aria-label="Max retries"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Retry delay (min)"
|
||||
control={
|
||||
<Input
|
||||
type="number"
|
||||
inputSize="sm"
|
||||
value={String(retryDelayMinutes)}
|
||||
onChange={(e) =>
|
||||
setRetryDelayMinutes(
|
||||
Math.max(0, Number(e.target.value) || 0),
|
||||
)
|
||||
}
|
||||
aria-label="Retry delay minutes"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sources step — kept in code, out of the flow for this release
|
||||
(SOURCES_IN_FLOW), since sources are always "editor" for now. */}
|
||||
{SOURCES_IN_FLOW && step === 3 && (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
Choose where this policy runs and which document types it applies
|
||||
to.
|
||||
</p>
|
||||
<p className="pol-section-label">Sources</p>
|
||||
<Card padding="none">
|
||||
{sourceDefs.map((src, i) => (
|
||||
<div
|
||||
key={src.id}
|
||||
className="pol-source"
|
||||
data-first={i === 0 || undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={sources.includes(src.id)}
|
||||
onChange={() => toggleSource(src.id)}
|
||||
leadingIcon={src.icon}
|
||||
label={src.label}
|
||||
description={src.desc}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<p className="pol-section-label">Document types</p>
|
||||
{!classificationEnabled ? (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
title="All document types"
|
||||
description="Enable the Classification policy to filter by document type."
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSetupClassification}
|
||||
>
|
||||
Set up Classification
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card padding="none">
|
||||
<div className="pol-doctypes-head">
|
||||
<span className="pol-field-label">
|
||||
{scopeTypes.length === 0
|
||||
? "All document types"
|
||||
: `${scopeTypes.length} types selected`}
|
||||
</span>
|
||||
<button
|
||||
className="pol-link"
|
||||
onClick={() => setScopeNarrow((v) => !v)}
|
||||
>
|
||||
{scopeNarrow ? "Clear" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
{scopeNarrow && (
|
||||
<div className="pol-doctypes">
|
||||
{docTypes.map((dt) => (
|
||||
<Checkbox
|
||||
key={dt}
|
||||
checked={scopeTypes.includes(dt)}
|
||||
onChange={() =>
|
||||
setScopeTypes((prev) =>
|
||||
prev.includes(dt)
|
||||
? prev.filter((d) => d !== dt)
|
||||
: [...prev, dt],
|
||||
)
|
||||
}
|
||||
label={dt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === TOTAL_STEPS && (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
When Stirling has low confidence in an enforcement action, it will
|
||||
send the document for human review.
|
||||
</p>
|
||||
<p className="pol-section-label">Reviewer</p>
|
||||
<Card padding="default">
|
||||
<FormField
|
||||
label="Send flagged documents to:"
|
||||
helperText="They'll open flagged documents directly in the Stirling editor."
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
inputSize="sm"
|
||||
value={reviewerEmail}
|
||||
onChange={(e) => setReviewerEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</FormField>
|
||||
</Card>
|
||||
|
||||
<p className="pol-section-label">Summary</p>
|
||||
<Card padding="default">
|
||||
<div className="pol-summary-head">
|
||||
<IconBadge accent="blue" size="sm">
|
||||
{category.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-summary-title">
|
||||
{category.label} Policy
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-summary-rows">
|
||||
<DataRow label="Enforces" align="top">
|
||||
<ChipFlow items={config.rules} />
|
||||
</DataRow>
|
||||
{SOURCES_IN_FLOW && (
|
||||
<DataRow label="Sources">{sources.length} selected</DataRow>
|
||||
)}
|
||||
<DataRow label="Reviewer">
|
||||
{reviewerEmail || <span className="pol-muted">Not set</span>}
|
||||
</DataRow>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pol-footer">
|
||||
<Button variant="ghost" size="sm" onClick={back}>
|
||||
{step > 1 ? "Back" : "Cancel"}
|
||||
</Button>
|
||||
{step < TOTAL_STEPS ? (
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{isEdit ? "Save Changes" : "Enable Policy"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
|
||||
import { Card } from "@shared/components/Card";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
/** One tool in a policy's fixed chain: whether it runs + its configured params. */
|
||||
export interface PolicyToolState {
|
||||
/** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */
|
||||
operation: string;
|
||||
/** Whether this tool runs as part of the policy (the per-tool on/off). */
|
||||
enabled: boolean;
|
||||
/** Tool-specific parameters (the shape its endpoint accepts). */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PolicyToolConfigProps {
|
||||
/** The policy's fixed tool chain — locked (no add/remove), only configurable. */
|
||||
tools: PolicyToolState[];
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
onChange: (tools: PolicyToolState[]) => void;
|
||||
/** Read-only when the policy is managed / the user can't configure. */
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked, configure-only tool panel for a policy. The chain is fixed (you can't
|
||||
* add or remove tools); each tool is a section that renders its OWN settings form
|
||||
* from the tool registry (`automationSettings`) — the same forms the automation
|
||||
* builder uses — so the config is generated from the tools in the workflow, not
|
||||
* hardcoded per policy. The parameters produced here are exactly what the backend
|
||||
* engine POSTs to each tool's endpoint.
|
||||
*/
|
||||
export function PolicyToolConfig({
|
||||
tools,
|
||||
toolRegistry,
|
||||
onChange,
|
||||
editable = true,
|
||||
}: PolicyToolConfigProps) {
|
||||
const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
|
||||
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
|
||||
|
||||
return (
|
||||
<div className="pol-tool-config">
|
||||
{tools.map((tool, index) => {
|
||||
const entry = toolRegistry[tool.operation as ToolId];
|
||||
const Settings = entry?.automationSettings ?? null;
|
||||
return (
|
||||
<Card key={tool.operation} padding="none">
|
||||
<div className="pol-tool-head">
|
||||
<span className="pol-tool-icon">{entry?.icon}</span>
|
||||
<span className="pol-tool-name">
|
||||
{entry?.name ?? tool.operation}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={tool.enabled}
|
||||
disabled={!editable}
|
||||
onChange={(checked) => patchTool(index, { enabled: checked })}
|
||||
aria-label={`Enable ${entry?.name ?? tool.operation}`}
|
||||
/>
|
||||
</div>
|
||||
{tool.enabled &&
|
||||
(tool.operation === "redact" ? (
|
||||
// Redact has a bespoke config: PII preset dropdown + a custom
|
||||
// word/regex field + advanced options, mode + regex locked on.
|
||||
<div className="pol-tool-body">
|
||||
<PolicyRedactConfig
|
||||
parameters={tool.parameters}
|
||||
onChange={(parameters) => patchTool(index, { parameters })}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
) : Settings ? (
|
||||
<div className="pol-tool-body">
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<Settings
|
||||
parameters={tool.parameters}
|
||||
onParameterChange={(key: string, value: unknown) =>
|
||||
patchTool(index, {
|
||||
parameters: { ...tool.parameters, [key]: value },
|
||||
})
|
||||
}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect, type MutableRefObject } from "react";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { buildPipelineDefinition } from "@app/services/policyPipeline";
|
||||
import {
|
||||
PolicyToolConfig,
|
||||
type PolicyToolState,
|
||||
} from "@app/components/policies/PolicyToolConfig";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import type { AutomationOperation } from "@app/types/automation";
|
||||
|
||||
/**
|
||||
* Seed a tool's parameters: start from the tool's own registry defaults, overlay
|
||||
* the preset's defaults (e.g. the PII patterns), then apply only the saved
|
||||
* values the user actually changed from the tool default. This means a policy
|
||||
* saved while a param was at its default (an empty redact list) still inherits
|
||||
* the preset value, while genuine user edits are preserved.
|
||||
*/
|
||||
function seedToolParameters(
|
||||
registryDefaults: Record<string, unknown>,
|
||||
presetParams: Record<string, unknown>,
|
||||
savedParams: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = {
|
||||
...registryDefaults,
|
||||
...presetParams,
|
||||
};
|
||||
const eq = (a: unknown, b: unknown) =>
|
||||
JSON.stringify(a) === JSON.stringify(b);
|
||||
for (const [key, value] of Object.entries(savedParams)) {
|
||||
if (!eq(value, registryDefaults[key])) merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
interface PolicyToolConfigStepProps {
|
||||
/** The fixed, configurable tool chain (locked set) for this policy. */
|
||||
chainIds: string[];
|
||||
/** Operations to seed enabled/params from (saved ops, or preset defaults). */
|
||||
initialOperations: AutomationOperation[];
|
||||
/**
|
||||
* The preset's default operations. Their params seed any tool whose saved
|
||||
* value is still at the tool's own default — so e.g. a policy saved before the
|
||||
* PII patterns existed inherits them rather than running with an empty list.
|
||||
*/
|
||||
presetOperations: AutomationOperation[];
|
||||
/** Used to name the built pipeline definition. */
|
||||
categoryLabel: string;
|
||||
/** The wizard triggers this on its final submit (mirrors PolicyWorkflowStep). */
|
||||
saveTriggerRef: MutableRefObject<(() => void) | null>;
|
||||
/** Emits the enabled tools as operations + the endpoint-mapped backend steps. */
|
||||
onComplete: (
|
||||
operations: AutomationOperation[],
|
||||
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
|
||||
unresolvedOps: string[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wizard's Workflow step for preset (tool-chain) policies: the locked,
|
||||
* per-tool config ({@link PolicyToolConfig}) instead of the add/remove builder.
|
||||
* Isolates the ToolWorkflow dependency (so it's mockable in the rail tests) and
|
||||
* wires the wizard's submit trigger to emit the configured tools as operations
|
||||
* + the endpoint-mapped pipeline steps.
|
||||
*/
|
||||
export function PolicyToolConfigStep({
|
||||
chainIds,
|
||||
initialOperations,
|
||||
presetOperations,
|
||||
categoryLabel,
|
||||
saveTriggerRef,
|
||||
onComplete,
|
||||
}: PolicyToolConfigStepProps) {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
|
||||
const [tools, setTools] = useState<PolicyToolState[]>(() =>
|
||||
chainIds.map((op) => {
|
||||
const saved = initialOperations.find((o) => o.operation === op);
|
||||
const preset = presetOperations.find((o) => o.operation === op);
|
||||
const defaults = (toolRegistry[op as ToolId]?.operationConfig
|
||||
?.defaultParameters ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
operation: op,
|
||||
enabled: Boolean(saved),
|
||||
parameters: seedToolParameters(
|
||||
defaults,
|
||||
(preset?.parameters ?? {}) as Record<string, unknown>,
|
||||
(saved?.parameters ?? {}) as Record<string, unknown>,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-wire the submit trigger whenever the tools change so it emits the latest.
|
||||
useEffect(() => {
|
||||
saveTriggerRef.current = () => {
|
||||
const operations: AutomationOperation[] = tools
|
||||
.filter((t) => t.enabled)
|
||||
.map((t) => ({ operation: t.operation, parameters: t.parameters }));
|
||||
const { definition, unresolved } = buildPipelineDefinition(
|
||||
{ name: `${categoryLabel} Policy`, operations },
|
||||
toolRegistry,
|
||||
);
|
||||
onComplete(operations, definition.steps, unresolved);
|
||||
};
|
||||
}, [tools, toolRegistry, categoryLabel, saveTriggerRef, onComplete]);
|
||||
|
||||
return (
|
||||
<PolicyToolConfig
|
||||
tools={tools}
|
||||
toolRegistry={toolRegistry}
|
||||
onChange={setTools}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
|
||||
import { AutomationMode } from "@app/types/automation";
|
||||
import type { AutomationConfig } from "@app/types/automation";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface PolicyWorkflowStepProps {
|
||||
/**
|
||||
* The automation to seed/edit. For setup this is a synthetic config carrying
|
||||
* the category preset's operations; for edit it's the policy's backing
|
||||
* automation.
|
||||
*/
|
||||
automation: AutomationConfig;
|
||||
/** SUGGESTED seeds-then-creates (setup); EDIT updates in place (settings). */
|
||||
mode: AutomationMode;
|
||||
/** The host (wizard) triggers the builder's save imperatively from its footer. */
|
||||
saveTriggerRef: MutableRefObject<(() => void) | null>;
|
||||
/**
|
||||
* Called with the saved automation once the builder persists it, plus the tool
|
||||
* registry (which lives here) so the wizard can map operations to backend
|
||||
* endpoint paths without depending on the ToolWorkflow context itself.
|
||||
*/
|
||||
onComplete: (
|
||||
automation: AutomationConfig,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
) => void;
|
||||
/** Called when save is triggered but the workflow isn't in a saveable state. */
|
||||
onSaveFailed?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy wizard's "Workflow" step: the Watch Folders automation builder
|
||||
* ({@link AutomationCreation}) reused to define a policy's tool pipeline. Kept
|
||||
* as its own component so the heavy builder + its ToolWorkflow dependency are
|
||||
* isolated (and mockable in the rail tests).
|
||||
*/
|
||||
export function PolicyWorkflowStep({
|
||||
automation,
|
||||
mode,
|
||||
saveTriggerRef,
|
||||
onComplete,
|
||||
onSaveFailed,
|
||||
}: PolicyWorkflowStepProps) {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
return (
|
||||
<AutomationCreation
|
||||
mode={mode}
|
||||
existingAutomation={automation}
|
||||
toolRegistry={toolRegistry}
|
||||
hideMetadata
|
||||
nameOverride={automation.name}
|
||||
saveTriggerRef={saveTriggerRef}
|
||||
onBack={() => {}}
|
||||
onComplete={(saved) => onComplete(saved, toolRegistry)}
|
||||
onSaveFailed={onSaveFailed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { AutomationMode };
|
||||
@@ -0,0 +1,86 @@
|
||||
# Policies (frontend)
|
||||
|
||||
Automation-backed document-enforcement policies — conceptually like Watch
|
||||
Folders but backend-driven, with non-folder triggers (editor save/export,
|
||||
device sweeps, cloud connectors). **This is the frontend only**: per-policy
|
||||
state is persisted locally (localStorage) and activity + stats are derived from
|
||||
your real uploaded files; server persistence + real enforcement land in a
|
||||
follow-up. It ships behind the `POLICIES_ENABLED` feature flag (proprietary
|
||||
build = on while in development, core build = off).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `types/policies.ts` | Type model (category, fields, state). |
|
||||
| `data/policyDefinitions.tsx` | Static preset definitions for the catalog: 5 categories (with the `providesClassification` data flag), per-category config fields, sources, doc types, and each category's default tool pipeline. Read it through `policyCatalog`, not directly. |
|
||||
| `services/policyCatalog.ts` | **The definitions seam.** `loadPolicyCatalog()` returns categories/configs/sources/doc-types. Components reach definitions only through here (via `usePolicyCatalog`) — swap this one function for a backend fetch to go live without touching a component. |
|
||||
| `hooks/usePolicyCatalog.ts` | Hook over the catalog seam (memoised; where loading/error state lands when it becomes async). |
|
||||
| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy **state** + change events. Swap this layer for the real API. |
|
||||
| `hooks/usePolicies.ts` | State + lifecycle actions + permission flag. |
|
||||
| `services/policyLiveData.ts` | Derives the detail view's activity feed + stats from the user's real uploaded files. |
|
||||
| `components/policies/PoliciesSidebar.tsx` | The three right-rail slots: list section, detail takeover, collapsed-rail icons (+ `usePoliciesEnabled` / `usePolicyDetailActive`). Shadows the core stub. |
|
||||
| `components/policies/policySelectionStore.ts` | Shared selected-policy / detail-view store the three slots sync through. |
|
||||
| `components/policies/PolicySetupWizard.tsx` | 3-step setup (operations → sources/types → reviewer/confirm). |
|
||||
| `components/policies/PolicyDetailPanel.tsx` | Configured "narrative" view (Enforces / Activity / Stats). |
|
||||
| `components/policies/PolicySettingsForm.tsx` | Edit-settings sub-view. |
|
||||
| `components/policies/PolicyFieldRow.tsx` | toggle / select / chips / text field renderer (SUI `SettingsRow` + `ToggleSwitch`/`Select`/`Input`/`Chip`). |
|
||||
|
||||
The core build gets a no-op stub at `core/components/policies/PoliciesSidebar.tsx`;
|
||||
`RightSidebar` (core) consumes the seam, so the section appears only in
|
||||
proprietary builds.
|
||||
|
||||
## Design system (SUI + Mantine)
|
||||
|
||||
The surface is composed almost entirely from the shared SUI design system
|
||||
(`@shared/components`), mixed with Mantine only where SUI has no equivalent.
|
||||
SUI components used here: `PanelHeader` (+ leading `IconBadge`), `Card`,
|
||||
`Button`, `Chip`, `ChipFlow`, `StatusBadge`, `Banner`, `EmptyState`,
|
||||
`MetricCard`, `Input`, `Select`, `ToggleSwitch`, `Checkbox`, `FormField`,
|
||||
`NavItem` (status `accent`), `ListRow`, `DataRow`, `SectionHeader`,
|
||||
`StepIndicator`. Several of those (`IconBadge`, `ListRow`, `DataRow`,
|
||||
`SectionHeader`, `StepIndicator`, `ChipFlow`, `SettingsRow`, plus the `NavItem`
|
||||
accent / `PanelHeader` icon slot / `Checkbox` leadingIcon / `MetricCard size`)
|
||||
were **built up in SUI** as part of this work — each has a Storybook story.
|
||||
|
||||
Bootstrapping: the editor loads `@shared/tokens/tokens.css` globally via
|
||||
`RainbowThemeProvider`, which also mirrors the Mantine colour scheme onto
|
||||
`<html data-theme>` (SUI's dark palette keys on `data-theme`). A global
|
||||
`@shared` alias in `editor/vite.config.ts` + `vitest.config.ts` resolves the
|
||||
shared components' own `@shared/*.css` self-imports.
|
||||
|
||||
The bespoke `.pol-*` CSS in `Policies.css` is now only thin layout scaffolding
|
||||
(detail/scroll/footer wrappers, the collapsed rail, row insets that match SUI
|
||||
`ListRow`); spacing snaps to the SUI `--space-*` scale and colour to the SUI
|
||||
token set.
|
||||
|
||||
**Status-colour convention (locked):** blue = accent/identity (NavItem accent
|
||||
bar, rail icon, detail Card accent — the prototype's blue); green `success`
|
||||
StatusBadge = the "Active" pill/dot everywhere (list + detail + rail dot);
|
||||
amber = paused. Configured rows render as raised cards (surface + border).
|
||||
|
||||
## Faithful to the prototype
|
||||
|
||||
5 categories (Ingestion, Security, Compliance, Routing, Retention), their full
|
||||
field sets, the 3-step wizard (incl. the doc-type step gated behind the
|
||||
Classification/ingestion policy), the configured narrative view (Enforces /
|
||||
recent-activity feed / three-up stats), settings, the permission model
|
||||
(owner/admin/member + solo), and the
|
||||
docked right-sidebar placement: a collapsible **Policies** list above Tools, a
|
||||
detail view that takes over the rail when a policy is open, and a collapsed-rail
|
||||
of policy icons with active/paused status dots.
|
||||
|
||||
## Deviations / follow-ups
|
||||
|
||||
- **Billing upgrade flows.** The prototype's free → pay-as-you-go → enterprise
|
||||
upgrade/commit/bespoke modals live in the Settings billing tab — a
|
||||
billing-integration surface (Stripe/org state that doesn't exist yet),
|
||||
deferred. The in-rail surface shows only the spend-limit warning chip, as in
|
||||
the prototype's policy section.
|
||||
- **Backend.** All persistence, enforcement, activity, and stats are mock. To
|
||||
go live, replace `services/policyStorage.ts` and feed real activity/stats.
|
||||
|
||||
## Tests
|
||||
|
||||
`services/policyStorage.test.ts` (seed/update/reset/heal/events) and
|
||||
`data/policyDefinitions.test.ts` (permission matrix + definition integrity).
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
dispatchKey,
|
||||
isDispatched,
|
||||
markDispatched,
|
||||
recordRunStart,
|
||||
updateRun,
|
||||
resetPolicyRuns,
|
||||
type PolicyRunRecord,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
|
||||
function rec(over: Partial<PolicyRunRecord>): PolicyRunRecord {
|
||||
return {
|
||||
runId: "r1",
|
||||
categoryId: "security",
|
||||
fileId: "f1",
|
||||
fileName: "f.pdf",
|
||||
fileSize: 10,
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// The store reads localStorage at import; reset state + storage per test.
|
||||
function read(key: string) {
|
||||
return JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
}
|
||||
|
||||
describe("policyRunStore", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
});
|
||||
|
||||
it("records a run start and marks the (policy, file) pair dispatched", () => {
|
||||
expect(isDispatched("security", "f1")).toBe(false);
|
||||
recordRunStart(rec({}));
|
||||
expect(isDispatched("security", "f1")).toBe(true);
|
||||
const stored = read("stirling-policy-runs");
|
||||
expect(stored.runs).toHaveLength(1);
|
||||
expect(stored.dispatched).toContain(dispatchKey("security", "f1"));
|
||||
});
|
||||
|
||||
it("markDispatched is idempotent and independent of a run record", () => {
|
||||
markDispatched("routing", "f9");
|
||||
markDispatched("routing", "f9");
|
||||
expect(isDispatched("routing", "f9")).toBe(true);
|
||||
expect(read("stirling-policy-runs").dispatched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updateRun patches an in-flight run's status + outputs", () => {
|
||||
recordRunStart(rec({ runId: "abc" }));
|
||||
updateRun("abc", {
|
||||
status: "COMPLETED",
|
||||
outputs: [{ fileId: "out-1", fileName: "redacted.pdf" }],
|
||||
});
|
||||
const run = read("stirling-policy-runs").runs[0];
|
||||
expect(run.status).toBe("COMPLETED");
|
||||
expect(run.outputs).toEqual([
|
||||
{ fileId: "out-1", fileName: "redacted.pdf" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("updateRun ignores an unknown run id", () => {
|
||||
recordRunStart(rec({ runId: "abc", status: "PENDING" }));
|
||||
updateRun("nope", { status: "FAILED" });
|
||||
expect(read("stirling-policy-runs").runs[0].status).toBe("PENDING");
|
||||
});
|
||||
|
||||
it("caps stored runs at 50, newest first", () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
recordRunStart(rec({ runId: `r${i}`, fileId: `f${i}`, startedAt: i }));
|
||||
}
|
||||
const runs = read("stirling-policy-runs").runs;
|
||||
expect(runs).toHaveLength(50);
|
||||
expect(runs[0].runId).toBe("r54"); // most recent
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* External store for real backend policy runs (Phase B: auto-run on upload).
|
||||
*
|
||||
* The auto-run controller fires a backend run for each enabled policy × each
|
||||
* newly-uploaded file and records it here; the detail view's activity feed reads
|
||||
* from it. `dispatched` keys (`categoryId:fileId`) ensure a given file is only
|
||||
* ever run once per policy, surviving remounts via localStorage.
|
||||
*
|
||||
* Read with {@code useSyncExternalStore}; mutated by the controller.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { PolicyRunStatus } from "@app/services/policyPipeline";
|
||||
|
||||
export interface PolicyRunRecord {
|
||||
runId: string;
|
||||
categoryId: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
status: PolicyRunStatus;
|
||||
/** Output files (downloadable via /api/v1/general/files/{id}) once done. */
|
||||
outputs: { fileId: string; fileName: string }[];
|
||||
/** True once ALL outputs have been imported into the workspace. */
|
||||
imported?: boolean;
|
||||
/** Output fileIds already imported — tracked per-file so a partial failure
|
||||
* retries only the missing ones and never re-adds the ones that succeeded. */
|
||||
importedFileIds?: string[];
|
||||
error: string | null;
|
||||
/** Epoch ms when the run was dispatched. */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface RunState {
|
||||
runs: PolicyRunRecord[];
|
||||
dispatched: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "stirling-policy-runs";
|
||||
/** Cap stored runs so the activity log can't grow without bound. */
|
||||
const MAX_RUNS = 50;
|
||||
|
||||
function read(): RunState {
|
||||
try {
|
||||
const raw =
|
||||
typeof localStorage !== "undefined"
|
||||
? localStorage.getItem(STORAGE_KEY)
|
||||
: null;
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<RunState>;
|
||||
return {
|
||||
// Normalise older persisted records (which predate the `outputs` field)
|
||||
// so consumers can always rely on `outputs` being an array.
|
||||
runs: Array.isArray(parsed.runs)
|
||||
? parsed.runs.map((r) => ({
|
||||
...r,
|
||||
outputs: Array.isArray(r.outputs) ? r.outputs : [],
|
||||
importedFileIds: Array.isArray(r.importedFileIds)
|
||||
? r.importedFileIds
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Corrupt/unavailable storage — start empty.
|
||||
}
|
||||
return { runs: [], dispatched: [] };
|
||||
}
|
||||
|
||||
let state: RunState = read();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
} catch {
|
||||
// Best-effort persistence.
|
||||
}
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): RunState {
|
||||
return state;
|
||||
}
|
||||
|
||||
const SERVER_SNAPSHOT: RunState = { runs: [], dispatched: [] };
|
||||
function getServerSnapshot(): RunState {
|
||||
return SERVER_SNAPSHOT;
|
||||
}
|
||||
|
||||
/** Key identifying a single (policy, file) run attempt. */
|
||||
export function dispatchKey(categoryId: string, fileId: string): string {
|
||||
return `${categoryId}:${fileId}`;
|
||||
}
|
||||
|
||||
/** Whether this (policy, file) pair has already been dispatched. */
|
||||
export function isDispatched(categoryId: string, fileId: string): boolean {
|
||||
return state.dispatched.includes(dispatchKey(categoryId, fileId));
|
||||
}
|
||||
|
||||
/** Record a newly-dispatched run (marks it dispatched + adds the record). */
|
||||
export function recordRunStart(record: PolicyRunRecord) {
|
||||
const key = dispatchKey(record.categoryId, record.fileId);
|
||||
state = {
|
||||
runs: [record, ...state.runs].slice(0, MAX_RUNS),
|
||||
dispatched: state.dispatched.includes(key)
|
||||
? state.dispatched
|
||||
: [...state.dispatched, key],
|
||||
};
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Mark a (policy, file) pair dispatched without a run (e.g. dispatch failed). */
|
||||
export function markDispatched(categoryId: string, fileId: string) {
|
||||
const key = dispatchKey(categoryId, fileId);
|
||||
if (state.dispatched.includes(key)) return;
|
||||
state = { ...state, dispatched: [...state.dispatched, key] };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Patch an in-flight run's status/outputs/error as it progresses. */
|
||||
export function updateRun(runId: string, patch: Partial<PolicyRunRecord>) {
|
||||
let changed = false;
|
||||
const runs = state.runs.map((r) => {
|
||||
if (r.runId !== runId) return r;
|
||||
changed = true;
|
||||
return { ...r, ...patch };
|
||||
});
|
||||
if (!changed) return;
|
||||
state = { ...state, runs };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Reset the store — used by tests to isolate it. */
|
||||
export function resetPolicyRuns() {
|
||||
state = { runs: [], dispatched: [] };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function usePolicyRuns(): PolicyRunRecord[] {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => getSnapshot().runs,
|
||||
() => getServerSnapshot().runs,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Tiny external store for the currently-selected policy and its detail sub-view.
|
||||
*
|
||||
* The Policies surface is split across two slots in the right tool sidebar — the
|
||||
* list section (above Tools) and the detail takeover (which replaces Tools when a
|
||||
* policy is open) — plus the collapsed-rail icons. They live in different parts of
|
||||
* {@code RightSidebar}'s tree, so selection can't be component-local useState.
|
||||
* This module-level store (read via {@code useSyncExternalStore}) lets all three
|
||||
* stay in sync without threading a context through the core sidebar.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { PolicyDetailView } from "@app/types/policies";
|
||||
|
||||
interface PolicySelection {
|
||||
selectedId: string | null;
|
||||
detailView: PolicyDetailView;
|
||||
}
|
||||
|
||||
let state: PolicySelection = { selectedId: null, detailView: "detail" };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): PolicySelection {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Deterministic initial snapshot for SSR/hydration (never the mutable store). */
|
||||
const SERVER_SNAPSHOT: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
};
|
||||
function getServerSnapshot(): PolicySelection {
|
||||
return SERVER_SNAPSHOT;
|
||||
}
|
||||
|
||||
/** Open a policy's detail (resets the sub-view to the narrative). */
|
||||
export function selectPolicy(id: string | null) {
|
||||
state = { selectedId: id, detailView: "detail" };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Switch the open policy between its narrative and edit-settings sub-views. */
|
||||
export function setPolicyDetailView(view: PolicyDetailView) {
|
||||
if (state.detailView === view) return;
|
||||
state = { ...state, detailView: view };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the open policy and return to the list. */
|
||||
export function closePolicy() {
|
||||
selectPolicy(null);
|
||||
}
|
||||
|
||||
/** Reset to the initial state — used by tests to isolate the module store. */
|
||||
export function resetPolicySelection() {
|
||||
state = { selectedId: null, detailView: "detail" };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function usePolicySelection(): PolicySelection {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IconBadgeAccent } from "@shared/components/IconBadge";
|
||||
import type { PolicyRowStatus, PolicyState } from "@app/types/policies";
|
||||
|
||||
/** Derive a single row/detail status from a policy's persisted state. */
|
||||
export function deriveRowStatus(
|
||||
state: PolicyState | undefined,
|
||||
): PolicyRowStatus {
|
||||
if (!state?.configured) return "setup";
|
||||
if (state.status === "paused") return "paused";
|
||||
return "active";
|
||||
}
|
||||
|
||||
/** Human label for each row status. */
|
||||
export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
setup: "Set up",
|
||||
};
|
||||
|
||||
/** A soft tinted icon tile per category — gives each policy a calm identity colour. */
|
||||
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
ingestion: "blue",
|
||||
security: "purple",
|
||||
compliance: "green",
|
||||
routing: "amber",
|
||||
retention: "red",
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The fixed, configurable tool chain per policy category — the locked set the
|
||||
* config page shows (one section per tool). Tools can be configured + toggled
|
||||
* on/off, but not added or removed. Each id is a frontend tool-registry key (and
|
||||
* maps to that tool's backend endpoint via the registry's operationConfig).
|
||||
*
|
||||
* Only Security is wired today; other categories follow.
|
||||
*/
|
||||
export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
|
||||
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
|
||||
// by default comes from the preset's defaultOperations, not this list.
|
||||
security: ["redact", "watermark", "sanitize"],
|
||||
};
|
||||
|
||||
/** The configurable tool chain for a category, or null if it has none yet. */
|
||||
export function getPolicyToolChain(categoryId: string): string[] | null {
|
||||
return POLICY_TOOL_CHAINS[categoryId] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PolicyConfigDef, PolicyState } from "@app/types/policies";
|
||||
|
||||
/**
|
||||
* Resolve each field's effective value for a policy: the saved override from
|
||||
* state, falling back to the definition's default.
|
||||
*/
|
||||
export function resolveFieldValues(
|
||||
config: PolicyConfigDef,
|
||||
state: PolicyState,
|
||||
): Record<string, boolean | string | string[]> {
|
||||
const out: Record<string, boolean | string | string[]> = {};
|
||||
for (const f of config.fields) {
|
||||
out[f.key] = state.fieldValues[f.key] ?? f.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Auto-run controller (Phase B): every enabled policy enforces on every uploaded
|
||||
* file. Watches the session's files and, for each (active policy × not-yet-run
|
||||
* file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it
|
||||
* to completion, recording progress in {@link policyRunStore} for the activity
|
||||
* feed.
|
||||
*
|
||||
* Headless — call it from {@link PolicyAutoRunController}, which is mounted once
|
||||
* wherever the editor is open so enforcement happens regardless of whether the
|
||||
* policy panel is on screen. Each (policy, file) pair runs exactly once (tracked
|
||||
* in the run store), so re-renders and remounts don't re-fire.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import {
|
||||
runStoredPolicy,
|
||||
getPolicyRun,
|
||||
downloadPolicyOutput,
|
||||
} from "@app/services/policyApi";
|
||||
import type { PolicyRunStatus } from "@app/services/policyPipeline";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import {
|
||||
isDispatched,
|
||||
markDispatched,
|
||||
recordRunStart,
|
||||
updateRun,
|
||||
usePolicyRuns,
|
||||
type PolicyRunRecord,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
|
||||
/** Poll cadence + cap for a single run's status (≈2.5 min worst case). */
|
||||
const POLL_MS = 2000;
|
||||
const MAX_POLLS = 75;
|
||||
|
||||
function isTerminal(status: PolicyRunStatus): boolean {
|
||||
return (
|
||||
status === "COMPLETED" || status === "FAILED" || status === "CANCELLED"
|
||||
);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function usePolicyAutoRun(): void {
|
||||
const { fileStubs } = useAllFiles();
|
||||
const { addFiles } = useFileManagement();
|
||||
const { policies } = usePolicies();
|
||||
const runs = usePolicyRuns();
|
||||
// Run ids currently being polled / imported, so the effects never double-fire.
|
||||
const polling = useRef<Set<string>>(new Set());
|
||||
const importing = useRef<Set<string>>(new Set());
|
||||
|
||||
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
const active = Object.entries(policies).filter(
|
||||
([, s]) => s.configured && s.status === "active" && s.backendId,
|
||||
);
|
||||
for (const [categoryId, s] of active) {
|
||||
for (const stub of fileStubs) {
|
||||
if (isDispatched(categoryId, stub.id)) continue;
|
||||
// runPolicyOnFile marks dispatched synchronously before its first await.
|
||||
void runPolicyOnFile(
|
||||
categoryId,
|
||||
s.backendId as string,
|
||||
stub.id,
|
||||
stub.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [fileStubs, policies]);
|
||||
|
||||
// Poll each in-flight run to a terminal state.
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
for (const run of runs) {
|
||||
if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
|
||||
polling.current.add(run.runId);
|
||||
void poll(run.runId).finally(() => polling.current.delete(run.runId));
|
||||
}
|
||||
}, [runs]);
|
||||
|
||||
// Import each completed run's outputs into the workspace (each output once),
|
||||
// so the enforced file appears in the app rather than only on the backend.
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
for (const run of runs) {
|
||||
if (
|
||||
run.status !== "COMPLETED" ||
|
||||
run.imported ||
|
||||
!run.outputs?.length ||
|
||||
importing.current.has(run.runId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
importing.current.add(run.runId);
|
||||
void importOutputs(run, addFiles).finally(() =>
|
||||
importing.current.delete(run.runId),
|
||||
);
|
||||
}
|
||||
}, [runs, addFiles]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a completed run's not-yet-imported output files and add them to the
|
||||
* workspace. Per-output, via allSettled: each output is tracked once imported,
|
||||
* so a partial failure retries only the missing files on a later tick and the
|
||||
* ones that succeeded are never added twice. `imported` flips true only once
|
||||
* every output has landed.
|
||||
*/
|
||||
async function importOutputs(
|
||||
run: PolicyRunRecord,
|
||||
addFiles: (files: File[]) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const done = new Set(run.importedFileIds ?? []);
|
||||
const pending = run.outputs.filter((out) => !done.has(out.fileId));
|
||||
if (pending.length === 0) {
|
||||
updateRun(run.runId, { imported: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
pending.map(async (out) => {
|
||||
const blob = await downloadPolicyOutput(out.fileId);
|
||||
return {
|
||||
fileId: out.fileId,
|
||||
file: new File([blob], out.fileName || run.fileName, {
|
||||
type: blob.type || "application/pdf",
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const fetched = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ fileId: string; file: File }> =>
|
||||
r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value);
|
||||
if (fetched.length === 0) return; // all failed — retry the lot on a later tick
|
||||
|
||||
// Add the freshly-fetched files, then mark exactly those imported. If addFiles
|
||||
// throws we don't mark them, so they retry (without having been added).
|
||||
await addFiles(fetched.map((f) => f.file));
|
||||
const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
|
||||
updateRun(run.runId, {
|
||||
importedFileIds,
|
||||
imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the file's bytes, fire a backend run, and record it. Exported so the
|
||||
* activity feed's Retry action can re-run a policy on a previously-failed file.
|
||||
*/
|
||||
export async function runPolicyOnFile(
|
||||
categoryId: string,
|
||||
backendId: string,
|
||||
fileId: FileId,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
// Mark synchronously, before any await, so neither the dispatch effect nor a
|
||||
// rapid Retry click can double-fire while the file bytes load.
|
||||
markDispatched(categoryId, fileId);
|
||||
try {
|
||||
const file = await fileStorage.getStirlingFile(fileId);
|
||||
if (!file) return; // file gone; nothing to run (already marked above).
|
||||
const runId = await runStoredPolicy(backendId, [file]);
|
||||
recordRunStart({
|
||||
runId,
|
||||
categoryId,
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize: file.size,
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Dispatch failed (offline / backend error). Already marked dispatched so we
|
||||
// don't hammer; the absent run simply won't appear in the activity feed.
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll a run's status until it reaches a terminal state (or the cap). */
|
||||
async function poll(runId: string): Promise<void> {
|
||||
for (let i = 0; i < MAX_POLLS; i++) {
|
||||
await delay(POLL_MS);
|
||||
let view;
|
||||
try {
|
||||
view = await getPolicyRun(runId);
|
||||
} catch {
|
||||
continue; // transient — keep trying within the cap.
|
||||
}
|
||||
updateRun(runId, {
|
||||
status: view.status,
|
||||
outputs: view.outputs,
|
||||
error: view.error,
|
||||
});
|
||||
if (isTerminal(view.status)) return;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user