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:
Reece Browne
2026-06-10 15:57:08 +01:00
committed by GitHub
parent ebc28b0a14
commit 8dde4262ec
91 changed files with 7250 additions and 99 deletions
+19 -3
View File
@@ -1,12 +1,14 @@
import { resolve } from "node:path";
import type { StorybookConfig } from "@storybook/react-vite";
import tsconfigPaths from "vite-tsconfig-paths";
/**
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
* addon list is just the extras we want: theme switching + a11y auditing.
*
* Story files live next to their components in shared/ and portal/src/.
* MDX docs pages live in portal/src/docs/.
* Story files live next to their components in shared/, portal/src/, and
* editor/src/ — the design system is shared by BOTH apps, so both surface
* their stories here. MDX docs pages live in portal/src/docs/.
*/
const config: StorybookConfig = {
stories: [
@@ -14,6 +16,7 @@ const config: StorybookConfig = {
"../portal/src/**/*.stories.@(ts|tsx)",
"../shared/**/*.mdx",
"../shared/**/*.stories.@(ts|tsx)",
"../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
framework: {
@@ -28,13 +31,26 @@ const config: StorybookConfig = {
staticDirs: ["../portal/public"],
viteFinal: async (config) => {
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
// story imports resolve without needing the portal's vite config.
// portal story imports resolve without needing the portal's vite config.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@portal": resolve(__dirname, "../portal/src"),
"@shared": resolve(__dirname, "../shared"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
// shared Storybook can host editor components without duplicating the alias
// map here.
config.plugins = config.plugins ?? [];
config.plugins.push(
tsconfigPaths({
projects: [
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
],
}),
);
return config;
},
};
@@ -8193,6 +8193,9 @@ confirm = "Extract"
message = "This ZIP contains {{count}} files. Extract anyway?"
title = "Large ZIP File"
[policies]
sidebarTitle = "Policies"
[watchedFolders]
alreadyInFolder = "Already in this folder"
deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
@@ -17,6 +18,7 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import { useFolders } from "@app/contexts/FolderContext";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import {
@@ -572,6 +574,31 @@ function FolderCard({
);
}
/** Shield badges for the policies that have run on a file. */
function PolicyBadges({ fileId }: { fileId: string }) {
const badges = usePolicyFileBadges().get(fileId) ?? [];
if (badges.length === 0) return null;
return (
<span className="files-page-policy-badges" data-no-select>
{badges.slice(0, 3).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="files-page-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
);
}
interface FileCardProps {
file: StirlingFileStub;
isSelected: boolean;
@@ -731,6 +758,7 @@ function FileCard({
<span>{fileSize}</span>
<span>·</span>
<span>{fileDate}</span>
<PolicyBadges fileId={file.id as string} />
</div>
</div>
<div className="files-page-card-actions">
@@ -1315,6 +1343,7 @@ function FileRow({
)}
</span>
<FileOriginBadge origin={getFileOrigin(file)} compact />
<PolicyBadges fileId={file.id as string} />
{isInWorkspace && (
<span className="files-page-row-open-pill">
<span className="files-page-card-open-dot" />
@@ -484,6 +484,24 @@
gap: 0.4rem;
}
/* Policy activity badges (a shield per policy that has run on the file). */
.files-page-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.files-page-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` set inline to the policy accent; tint follows it. */
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
@@ -0,0 +1,38 @@
/**
* Core stubs for the right-rail Policies UI.
*
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail shows only the
* tool list unchanged.
*/
import type { ReactNode } from "react";
/** Whether the right rail should host the Policies section. False in core. */
export function usePoliciesEnabled(): boolean {
return false;
}
/**
* Whether a policy is open (its detail should take over the rail). Always false
* in core; proprietary bridges to the policy-selection store.
*/
export function usePolicyDetailActive(): boolean {
return false;
}
/** Collapsible policy list rendered above the Tools section. Null in core. */
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
return null;
}
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
export function PolicyDetailTakeover() {
return null;
}
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
@@ -0,0 +1,11 @@
/**
* Core stub for the policy auto-run controller.
*
* The real implementation lives in
* {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows
* this stub via the {@code @app/*} alias cascade in the proprietary build. Core
* builds have no Policies feature, so this renders nothing and runs no policies.
*/
export function PolicyAutoRunController() {
return null;
}
@@ -42,6 +42,7 @@ import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
setWatchedFolderDraggedFileIds,
clearWatchedFolderDraggedFileIds,
@@ -131,6 +132,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// the workbench). The same map drives the per-file membership dots.
const folderMembership = useFolderMembership();
const allFolders = useAllWatchedFolders();
const policyFileBadges = usePolicyFileBadges();
const folderById = useMemo(
() => new Map(allFolders.map((f) => [f.id, f])),
[allFolders],
@@ -892,6 +894,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
onFolderClick={openWatchedFolder}
policies={
policyFileBadges.get(stub.id as string) ?? []
}
/>
);
})}
@@ -134,6 +134,25 @@
text-overflow: ellipsis;
}
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
.file-sidebar-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.file-sidebar-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` is set inline to the policy's accent; the tint follows it. */
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* ---- Folder membership tags ---- */
.file-sidebar-folder-tags {
display: flex;
@@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
@@ -125,6 +126,14 @@ export interface FileItemFolderRef {
accentColor: string;
}
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
name: string;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
}
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -143,9 +152,12 @@ export interface FileItemProps {
folders?: FileItemFolderRef[];
/** Clicking a membership dot opens that folder. */
onFolderClick?: (folderId: string) => void;
/** Policies that have run on this file — rendered as small shield badges. */
policies?: FileItemPolicyRef[];
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
const MAX_VISIBLE_POLICY_BADGES = 3;
export function FileItem({
fileId,
@@ -162,6 +174,7 @@ export function FileItem({
onDragStart,
folders = [],
onFolderClick,
policies = [],
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
@@ -237,6 +250,25 @@ export function FileItem({
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
</span>
{policies.length > 0 && (
<span className="file-sidebar-policy-badges" data-no-select>
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="file-sidebar-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
)}
</span>
{folders.length > 0 && (
<span className="file-sidebar-folder-tags" data-no-select>
@@ -1,4 +1,4 @@
import { createContext, useContext, ReactNode } from "react";
import { createContext, useContext, useEffect, ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
import { mantineTheme } from "@app/theme/mantineTheme";
@@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast";
import ToastRenderer from "@app/components/toast/ToastRenderer";
import { ToastPortalBinder } from "@app/components/toast";
import type { ThemeMode } from "@app/constants/theme";
// SUI shared design-system tokens (used by @shared/components). Additive — its
// var names don't clash with the editor's own theme.css. The effect below
// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on.
import "@shared/tokens/tokens.css";
interface RainbowThemeContextType {
themeMode: ThemeMode;
@@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const mantineColorScheme =
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
// Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css
// keys its dark palette on (the editor otherwise only sets Mantine's
// data-mantine-color-scheme), so @shared/components theme correctly.
useEffect(() => {
document.documentElement.setAttribute(
"data-theme",
mantineColorScheme === "dark" ? "dark" : "light",
);
}, [mantineColorScheme]);
return (
<RainbowThemeContext.Provider value={rainbowTheme}>
<MantineProvider
@@ -13,6 +13,14 @@ import {
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import {
PoliciesCollapsedButton,
PoliciesSection,
PolicyDetailTakeover,
usePoliciesEnabled,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useChat } from "@app/components/chat/ChatContext";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
@@ -70,6 +78,8 @@ export default function RightSidebar() {
} = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const policiesEnabled = usePoliciesEnabled();
const rawPolicyDetailActive = usePolicyDetailActive();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
@@ -153,15 +163,35 @@ export default function RightSidebar() {
});
};
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
// default tool-picker view — the only view the policy takeover renders in — so
// it never collides with an open tool or the all-tools/search view.
const handleOpenPolicy = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Show search input whenever there's a close button, or when agents are off and
// we're in the default tool-picker view (search filters the full list inline).
// Policies sit above the tool list in the default tool-picker view.
const showPolicies =
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
// When Policies are shown, the search moves OUT of the header to sit between
// the Policies and Tools sections (separating them); otherwise it stays in the
// header. Show the header search when there's a close button, or when agents
// are off in the default tool-picker view (inline filtering).
const showInlineSearch = showPolicies && !showCloseButton;
const showHeaderSearch =
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
!showInlineSearch &&
(showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"));
const handleHeaderBack = () => {
if (inToolView) {
@@ -201,11 +231,21 @@ export default function RightSidebar() {
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
// The detail takeover replaces the tool list ONLY in the same default view —
// never over an open tool or the all-tools view (which must keep priority).
// A lingering selection is harmless: it stays hidden behind a tool and the
// list/takeover reappears on return to the picker (as in the prototype).
const policyDetailActive = rawPolicyDetailActive && showPolicies;
// The rail widens when a policy detail takes it over — the tool list is fine
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
const computedWidth = () => {
if (isMobile) return "100%";
if (isChatOpen) return `${chatWidthPx}px`;
if (!isPanelVisible) return "3.5rem";
return "18.5rem";
return expandedWidth;
};
// Collapsed rail: show favourites + recommended tools as icons.
@@ -248,6 +288,8 @@ export default function RightSidebar() {
...(isChatDragging ? { transition: "none" } : {}),
}}
>
{/* Headless: enforces enabled policies on every uploaded file. */}
{policiesEnabled && <PolicyAutoRunController />}
{!fullscreenExpanded && isChatOpen && (
<div
style={{
@@ -279,7 +321,7 @@ export default function RightSidebar() {
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
className="tool-panel__expand-btn tool-panel__toggle-vt"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
@@ -288,6 +330,7 @@ export default function RightSidebar() {
<AgentsCollapsedButton onExpand={handleExpand} />
</div>
<div className="tool-panel__collapsed-divider" />
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
@@ -326,84 +369,122 @@ export default function RightSidebar() {
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
width: isMobile ? "100%" : "18.5rem",
width: isMobile ? "100%" : expandedWidth,
flexShrink: 0,
display: "flex",
flexDirection: "column",
}}
>
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
{policyDetailActive ? (
<div className="pol-takeover">
<PolicyDetailTakeover />
</div>
) : (
<>
{!showPolicies && (
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showAgents && <AgentsSection />}
{showPolicies && (
<PoliciesSection
leadingControl={
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showAgents && <AgentsSection />}
{showInlineSearch && (
<div className="tool-panel__between-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
</>
)}
</div>
)}
@@ -201,6 +201,13 @@
border-color: var(--border-subtle) !important;
}
/* The collapse/expand toggle keeps a stable identity across the collapsed strip
and the expanded header, so a view transition translates it between the two
(same size) instead of cross-fading/scaling it ("big then small"). */
.tool-panel__toggle-vt {
view-transition-name: sidebar-toggle;
}
.tool-panel__expand-btn svg {
color: var(--text-secondary) !important;
}
@@ -271,6 +278,22 @@
width: 100%;
}
/* Search that separates the Policies section from the Tools list below it. */
.tool-panel__between-search {
padding: 0.5rem 0.75rem 0.25rem;
border-top: 1px solid var(--border-subtle, var(--color-border));
}
.tool-panel__between-search .search-input-container {
width: 100%;
margin-bottom: 0;
}
/* Slightly recessed search field so it reads as distinct from the panel. */
.tool-panel__between-search input {
background-color: var(--bg-muted);
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
@@ -4,11 +4,18 @@
*/
/**
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
* custom workbench registration (seeding, URL sync, view) and the sidebar
* entry point, so the feature is unreachable from the UI. Flip to `true` to
* restore access.
* Watched Folders. Disabled for now — gates both the custom workbench
* registration (seeding, URL sync, view) and the sidebar entry point, so the
* feature is unreachable from the UI. Flip to `true` to restore access.
*/
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
// as constant/unreachable conditions by the type checker and linter.
export const WATCHED_FOLDERS_ENABLED: boolean = false;
/**
* Policies — a proprietary, automation-backed feature (like Watched Folders but
* backend-driven, with non-folder triggers). The implementation lives under
* `proprietary/`; this core value stays `false` so the shared sidebar entry
* never appears in the open-source build.
*/
export const POLICIES_ENABLED: boolean = false;
@@ -17,7 +17,9 @@ export function useAllWatchedFolders(): WatchedFolder[] {
useEffect(() => {
const load = async () => {
try {
setFolders(await watchedFolderStorage.getAllFolders());
// Policy-owned folders are managed by Policies, not shown here.
const all = await watchedFolderStorage.getAllFolders();
setFolders(all.filter((f) => !f.policyCategoryId));
} catch (err) {
console.error("Failed to load smart folders:", err);
}
@@ -0,0 +1,10 @@
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
/**
* Policies that have run on each file, keyed by fileId — drives the shield
* badges in the file sidebar. Empty in core; the proprietary build shadows this
* with an implementation backed by the policy run store.
*/
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
return new Map();
}
@@ -248,6 +248,14 @@ export async function mockAppApis(
await page.route("**/api/v1/info/wau", (route: Route) =>
route.fulfill({ json: { count: 0 } }),
);
// Policies (proprietary): the reconcile fires GET /api/v1/policies on app
// load. Return an empty list so the stubbed (backend-free) env stays clean —
// no failed request polluting the console, and the auto-run controller has
// nothing to dispatch.
await page.route("**/api/v1/policies", (route: Route) =>
route.fulfill({ json: [] }),
);
}
/**
@@ -25,6 +25,9 @@ export interface WatchedFolder {
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
inputSource?: "idb" | "local-folder";
/** Set when this folder is the backing store for a Policy (its category id).
* Policy-owned folders are filtered out of the Watched Folders UI. */
policyCategoryId?: string;
}
export interface FolderFileMetadata {
@@ -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 &amp; 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;
}
}
@@ -6,10 +6,17 @@
*/
/**
* Watched Folders (a.k.a. Watched Folders) a proprietary feature whose
* implementation lives under `proprietary/`. Still disabled for now; flip to
* `true` to surface it in the proprietary build only. The core override stays
* `false`, so the shared sidebar entry point never appears in the open-source
* build (which has no Watched Folders implementation to navigate to).
* Watched Folders a proprietary feature whose implementation lives under
* `proprietary/`. Still disabled for now; flip to `true` to surface it in the
* proprietary build only. The core override stays `false`, so the shared
* sidebar entry point never appears in the open-source build (which has no
* Watched Folders implementation to navigate to).
*/
export const WATCHED_FOLDERS_ENABLED: boolean = false;
/**
* Policies proprietary, automation-backed policy enforcement. Enabled in the
* proprietary build so it's reachable while in active development (frontend is
* mock/stub-backed; no real server yet). Core stays `false`.
*/
export const POLICIES_ENABLED: boolean = true;
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
describe("policy definitions integrity", () => {
it("every category has a matching config entry", () => {
for (const cat of POLICY_CATEGORIES) {
expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined();
expect(POLICY_CONFIG[cat.id].fields.length).toBeGreaterThan(0);
expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0);
// Every preset seeds a real, non-empty pipeline (the category→steps map).
expect(
POLICY_CONFIG[cat.id].defaultOperations.length,
`defaultOperations for ${cat.id}`,
).toBeGreaterThan(0);
}
});
it("field keys are unique within each category", () => {
for (const cat of POLICY_CATEGORIES) {
const keys = POLICY_CONFIG[cat.id].fields.map((f) => f.key);
expect(new Set(keys).size).toBe(keys.length);
}
});
});
@@ -0,0 +1,365 @@
/**
* Static preset definitions for Policies the categories, their editable
* settings fields, scope labels, and the default tool pipeline each category
* seeds a new policy with. Runtime activity + stats are derived live from the
* user's real files (see policyLiveData), not defined here.
*/
import LayersIcon from "@mui/icons-material/Layers";
import ShieldIcon from "@mui/icons-material/Shield";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import StorageIcon from "@mui/icons-material/Storage";
import DescriptionIcon from "@mui/icons-material/Description";
import ComputerIcon from "@mui/icons-material/Computer";
import PublicIcon from "@mui/icons-material/Public";
import CloudIcon from "@mui/icons-material/Cloud";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import type {
PolicyCategory,
PolicyConfigDef,
PolicySource,
} from "@app/types/policies";
const ICON_SX = { fontSize: "1rem" } as const;
/** The 5 policy categories, in the prototype's narrative order. */
export const POLICY_CATEGORIES: PolicyCategory[] = [
{
id: "ingestion",
label: "Ingestion",
icon: <LayersIcon sx={ICON_SX} />,
desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
// The classifier the wizard's "Set up Classification" action routes to.
providesClassification: true,
// Needs the classify agent + RAG, which aren't built yet.
comingSoon: true,
},
{
id: "security",
label: "Security",
icon: <ShieldIcon sx={ICON_SX} />,
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
},
{
id: "compliance",
label: "Compliance",
icon: <CheckCircleIcon sx={ICON_SX} />,
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
comingSoon: true,
},
{
id: "routing",
label: "Routing",
icon: <ArrowForwardIcon sx={ICON_SX} />,
desc: "Auto-route documents to the right team, folder, or system.",
comingSoon: true,
},
{
id: "retention",
label: "Retention",
icon: <StorageIcon sx={ICON_SX} />,
desc: "Set how long documents are kept, when to archive, and when to delete.",
comingSoon: true,
},
];
/**
* PII presets for the redact step: a label + the regex the /auto-redact endpoint
* matches (via `wordsToRedact` + `useRegex`). Patterns are precise validated
* (SSN areas, card IINs, ABA prefixes), context- or separator-anchored to keep
* false positives down and avoid the backtracking the old broad patterns risked.
*/
export const PII_PRESETS: { value: string; label: string; pattern: string }[] =
[
{
value: "ssn",
label: "Social Security numbers",
// 123-45-6789 or 123 45 6789; rejects invalid areas (000/666/9xx),
// group 00, serial 0000, and mixed separators (backreference).
pattern:
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
},
{
value: "card",
label: "Credit / debit cards",
// Solid runs anchored to real IINs (Visa 13/16, MC 5155 + 22212720,
// Amex 34/37, Discover 6011/65xx) + grouped 4-4-4-4 and Amex 4-6-5
// with a consistent separator enforced by backreference.
pattern:
"\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|(?:222[1-9]|22[3-9]\\d|2[3-6]\\d{2}|27[01]\\d|2720)\\d{12}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12}|[2-6]\\d{3}([ -])\\d{4}\\1\\d{4}\\1\\d{4}|3[47]\\d{2}([ -])\\d{6}\\2\\d{5})\\b",
},
{
value: "iban",
label: "IBANs",
// Solid (GB29NWBK…) or space-grouped (GB29 NWBK 6016 …) form.
pattern:
"\\b[A-Z]{2}\\d{2}(?:[A-Z0-9]{11,30}|(?: [A-Z0-9]{4}){2,7}(?: [A-Z0-9]{1,4})?)\\b",
},
{
value: "routing",
label: "US routing numbers (ABA)",
// 9 digits constrained to valid Federal Reserve prefix ranges.
pattern: "\\b(?:0[1-9]|1[0-2]|2[1-9]|3[0-2]|6[1-9]|7[0-2]|80)\\d{7}\\b",
},
{
value: "account",
label: "Account numbers (labelled)",
// Context-anchored: only digits preceded by Account / Acct / A/C.
pattern:
"\\b(?:[Aa]cc(?:oun)?t|[Aa]/[Cc])(?:\\s+(?:[Nn]o\\.?|[Nn]umber|#))?\\s*[:#]?\\s*\\d{6,17}\\b",
},
{
value: "email",
label: "Email addresses",
// Requires a real TLD (≥2 letters); won't swallow a sentence-ending period.
pattern:
"\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}\\b",
},
{
value: "phone",
label: "Phone numbers",
// (555) 123-4567 · 555-123-4567 (consistent separator) · +E.164 solid or
// grouped · UK 0-prefixed grouped formats. Bare 10-digit runs excluded.
pattern:
"\\(\\d{3}\\)[ .-]?\\d{3}[ .-]?\\d{4}\\b|\\b\\d{3}([ .-])\\d{3}\\1\\d{4}\\b|\\+\\d{1,3}[ .-]?\\d{6,12}\\b|\\+\\d{1,3}(?:[ .-]\\d{2,4}){2,5}\\b|\\b0\\d{2,4}[ -]\\d{3,4}[ -]?\\d{3,4}\\b",
},
];
/**
* Defaults seeded into a fresh Security policy's redact step only the two
* strictest, precise patterns (SSN + cards). Users add the rest (IBAN, routing,
* account, email, phone) from the PII dropdown.
*/
export const DEFAULT_PII_PATTERNS: string[] = [
PII_PRESETS[0].pattern, // SSN
PII_PRESETS[1].pattern, // cards
];
/** Per-category narrative + editable fields (from the prototype's POLICY_CONFIG). */
export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
ingestion: {
summary:
"Classifies documents, extracts structured data, enforces naming, and normalizes pages.",
rules: ["Classify", "Extract", "Name", "Normalize"],
defaultOperations: [
{ operation: "ocr", parameters: {} },
{ operation: "flatten", parameters: {} },
],
scopeLabel: "All PDFs on this device",
// Policy-level controls only — the per-tool params (OCR level, extract
// tables, naming, normalize, rotate…) now live in the Workflow step.
fields: [
{
label: "Min confidence",
key: "minConfidence",
type: "select",
value: "80%",
options: ["60%", "70%", "80%", "90%", "95%"],
},
{
label: "Below threshold",
key: "belowThreshold",
type: "select",
value: "Flag for review",
options: ["Flag for review", "Route to bucket", "Hold"],
},
],
},
security: {
summary:
"Detects PII, encrypts, verifies authenticity, controls access, and certifies documents.",
rules: ["Redact PII", "Remove JavaScript"],
// Default chain: redact PII + remove JavaScript (via sanitize) on; watermark
// is offered in the config page but off by default (not seeded here). Redact
// ships with the high-risk PII regexes so it works out of the box.
defaultOperations: [
{
operation: "redact",
parameters: {
mode: "automatic",
useRegex: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{ operation: "sanitize", parameters: {} },
],
scopeLabel: "All PDFs on this device",
// Policy-level controls only — detection/encryption/signing/watermark are
// per-tool and now live in the Workflow step.
fields: [
{
label: "Default PII response",
key: "defaultResponse",
type: "select",
value: "Highlight & tag",
options: [
"Highlight & tag",
"Prompt on export",
"Auto-redact on export",
"Block export",
],
},
{
label: "User can override",
key: "userOverride",
type: "toggle",
value: true,
},
{
label: "Default access level",
key: "defaultAccess",
type: "select",
value: "Restricted",
options: ["Open", "Restricted", "Confidential", "Top Secret"],
},
{
label: "Block external sharing",
key: "blockExternal",
type: "toggle",
value: false,
},
],
},
compliance: {
summary:
"Validates documents against regulatory frameworks before they leave the system.",
rules: ["Framework scan", "Enforce action", "Audit trail"],
defaultOperations: [
{ operation: "sanitize", parameters: {} },
{ operation: "flatten", parameters: {} },
],
scopeLabel: "All PDFs on this device",
fields: [
{
label: "Frameworks",
key: "frameworks",
type: "chips",
value: ["HIPAA"],
options: [
"HIPAA",
"GDPR",
"SOC 2",
"FedRAMP",
"PCI DSS",
"CCPA",
"ISO 27001",
],
},
{
label: "When non-compliant",
key: "onViolation",
type: "select",
value: "Flag for review",
options: [
"Flag for review",
"Block export",
"Auto-redact PHI",
"Quarantine document",
],
},
{ label: "Audit trail", key: "auditTrail", type: "toggle", value: true },
{ label: "Access log", key: "accessLog", type: "toggle", value: true },
],
},
routing: {
summary:
"Routes documents to the right destination based on type and classification.",
rules: ["Auto-classify", "Route to folder", "Webhook notify"],
defaultOperations: [{ operation: "compress", parameters: {} }],
scopeLabel: "All PDFs on this device",
fields: [
{
label: "Destination",
key: "destination",
type: "select",
value: "Documents",
options: ["Documents", "S3 bucket", "SharePoint", "Webhook"],
},
{ label: "Webhook URL", key: "webhookUrl", type: "text", value: "" },
{ label: "Notify on route", key: "notify", type: "toggle", value: false },
],
},
retention: {
summary:
"Enforces how long documents are kept, when to archive, and when to delete.",
rules: ["Retention hold", "Auto-archive", "Deletion block"],
defaultOperations: [{ operation: "compress", parameters: {} }],
scopeLabel: "All PDFs on this device",
fields: [
{
label: "Keep for",
key: "keepFor",
type: "select",
value: "7 years",
options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"],
},
{
label: "Archive after",
key: "archiveAfter",
type: "select",
value: "Never",
options: ["30 days", "90 days", "1 year", "Never"],
},
{
label: "Immutable hold",
key: "immutableHold",
type: "toggle",
value: false,
},
],
},
};
/** Sources a policy can run over (wizard step 2). */
export const POLICY_SOURCES: PolicySource[] = [
{
id: "editor",
label: "Editor",
desc: "Documents you save or export in Stirling",
icon: <DescriptionIcon sx={ICON_SX} />,
},
{
id: "device",
label: "Entire device",
desc: "All PDFs on this machine, retroactively",
icon: <ComputerIcon sx={ICON_SX} />,
},
{
id: "sharepoint",
label: "SharePoint",
desc: "Connected SharePoint libraries",
icon: <PublicIcon sx={ICON_SX} />,
},
{
id: "dropbox",
label: "Dropbox",
desc: "Connected Dropbox folders",
icon: <CloudIcon sx={ICON_SX} />,
},
{
id: "gmail",
label: "Gmail",
desc: "PDF attachments in email",
icon: <EmailOutlinedIcon sx={ICON_SX} />,
},
{
id: "gdrive",
label: "Google Drive",
desc: "Connected Drive folders",
icon: <FolderOpenIcon sx={ICON_SX} />,
},
];
/** Document types selectable when narrowing scope (gated behind classification). */
export const POLICY_DOC_TYPES: string[] = [
"Contracts",
"Invoices",
"Tax documents",
"HR records",
"Insurance",
"Medical / PHI",
"Legal filings",
"Financial reports",
];
@@ -0,0 +1,155 @@
import "fake-indexeddb/auto";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
// Enable/delete create + remove the backing Watched Folders WatchedFolder
// (IndexedDB); jsdom's crypto lacks randomUUID, used 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)}`,
});
}
// In-memory stand-in for the backend policy store, so the hook's persistence
// path is exercised without a real server.
const api = vi.hoisted(() => ({
store: new Map<string, { id: string }>(),
seq: 0,
}));
vi.mock("@app/services/policyApi", () => ({
listPolicies: vi.fn(async () => [...api.store.values()]),
savePolicy: vi.fn(async (p: { id?: string }) => {
const id = p.id && p.id.length > 0 ? p.id : `be-${++api.seq}`;
const saved = { ...p, id };
api.store.set(id, saved);
return saved;
}),
getPolicy: vi.fn(async (id: string) => api.store.get(id)),
deletePolicy: vi.fn(async (id: string) => {
api.store.delete(id);
}),
runStoredPolicy: vi.fn(),
runPolicyPipeline: vi.fn(),
getPolicyRun: vi.fn(),
}));
import { usePolicies } from "@app/hooks/usePolicies";
// A minimal wizard result (workflow already saved + mapped by the builder).
const wizardResult = {
automation: {
id: "auto-1",
name: "Test",
operations: [{ operation: "compress", parameters: {} }],
createdAt: "",
updatedAt: "",
},
fieldValues: {},
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "[email protected]",
folder: {
outputMode: "new_file" as const,
outputName: "",
outputNamePosition: "prefix" as const,
maxRetries: 3,
retryDelayMinutes: 5,
},
pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
unresolvedOps: [],
};
describe("usePolicies", () => {
beforeEach(() => {
localStorage.clear();
api.store.clear();
api.seq = 0;
});
it("starts with every category unconfigured (no seed)", async () => {
const { result } = renderHook(() => usePolicies());
// Flush the async mount reconcile (empty backend ⇒ all stay unconfigured).
await act(async () => {});
expect(result.current.policies.ingestion.configured).toBe(false);
expect(result.current.policies.security.configured).toBe(false);
});
it("enabling a policy persists it to the backend + marks it configured", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.enablePolicy("security", wizardResult);
});
await waitFor(() =>
expect(result.current.policies.security.configured).toBe(true),
);
expect(result.current.policies.security.status).toBe("active");
expect(result.current.policies.security.folderId).toBeTruthy();
expect(result.current.policies.security.backendId).toBeTruthy();
expect(result.current.policies.security.reviewerEmail).toBe(
"[email protected]",
);
// The mapped pipeline (endpoint path) reached the backend store.
const stored = [...api.store.values()][0] as unknown as {
steps: unknown[];
};
expect(stored.steps).toHaveLength(1);
});
it("reconciles configured policies from the backend on mount", async () => {
// Enable on one instance (persists to the backend store)...
const first = renderHook(() => usePolicies());
await act(async () => {
await first.result.current.enablePolicy("security", wizardResult);
});
// ...a fresh instance should pick it up from the backend.
const second = renderHook(() => usePolicies());
await waitFor(() =>
expect(second.result.current.policies.security.configured).toBe(true),
);
expect(second.result.current.policies.security.backendId).toBeTruthy();
});
it("pausing then resuming flips status", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.enablePolicy("ingestion", wizardResult);
});
await act(async () => {
await result.current.pausePolicy("ingestion");
});
expect(result.current.policies.ingestion.status).toBe("paused");
await act(async () => {
await result.current.resumePolicy("ingestion");
});
expect(result.current.policies.ingestion.status).toBe("active");
});
it("deleting a policy reverts it + removes it from the backend", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.enablePolicy("routing", wizardResult);
});
await waitFor(() =>
expect(result.current.policies.routing.configured).toBe(true),
);
await act(async () => {
await result.current.deletePolicy("routing");
});
expect(result.current.policies.routing.configured).toBe(false);
expect(result.current.policies.routing.status).toBe("default");
expect(result.current.policies.routing.folderId).toBeUndefined();
expect(result.current.policies.routing.backendId).toBeUndefined();
expect(api.store.size).toBe(0);
});
it("ensurePolicyFolder creates a backing folder for a folderless policy", async () => {
const { result } = renderHook(() => usePolicies());
await act(async () => {
await result.current.ensurePolicyFolder("ingestion");
});
expect(result.current.policies.ingestion.folderId).toBeTruthy();
});
});
@@ -0,0 +1,282 @@
/**
* State + actions for Policies. The backend (`/api/v1/policies`) is the source
* of truth: on mount we reconcile the local cache against the stored policies,
* and every lifecycle action (enable/save/pause/resume/delete) is mirrored to
* the backend. localStorage is a fast-render cache + offline fallback; the
* IndexedDB backing folder still holds the editable automation + run state.
*/
import { useState, useEffect, useCallback } from "react";
import {
loadPolicies,
onPoliciesChange,
updatePolicy,
resetPolicy,
} from "@app/services/policyStorage";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import {
createPolicyFolder,
createPolicyFolderForAutomation,
deletePolicyFolder,
getPolicyAutomation,
setPolicyFolderPaused,
updatePolicyFolderSettings,
updatePolicyOperations,
} from "@app/services/policyFolders";
import {
fetchPoliciesByCategory,
decodedToState,
findBackendId,
persistPolicy,
setPolicyEnabled,
removePolicy,
} from "@app/services/policyBackend";
import type { PolicyToStore } from "@app/services/policyPipeline";
import type {
PoliciesByCategory,
PolicyConfigResult,
PolicyWizardResult,
} from "@app/types/policies";
/** Build the backend store-request for a category from a wizard result. */
function toStoreRequest(
categoryId: string,
categoryLabel: string,
result: PolicyWizardResult,
enabled: boolean,
backendId: string | undefined,
): PolicyToStore {
return {
id: backendId,
categoryId,
name: `${categoryLabel} Policy`,
enabled,
automation: result.automation,
pipelineSteps: result.pipelineSteps,
sources: result.sources,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
fieldValues: result.fieldValues,
folder: result.folder,
};
}
export function usePolicies() {
const [policies, setPolicies] = useState<PoliciesByCategory>(loadPolicies);
useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []);
// Reconcile the local cache against the backend (the source of truth) on
// mount. Backend config wins; the locally-cached folderId (which the backend
// doesn't track) is preserved. If the backend is unreachable we keep the
// local cache as-is, so the surface still works offline.
useEffect(() => {
let cancelled = false;
void (async () => {
let byCategory;
try {
byCategory = await fetchPoliciesByCategory();
} catch {
return; // offline / backend down — local cache stands.
}
if (cancelled) return;
const local = loadPolicies();
const reconciled: PoliciesByCategory = {};
for (const cat of loadPolicyCatalog().categories) {
const decoded = byCategory.get(cat.id);
reconciled[cat.id] = decoded
? decodedToState(decoded, local[cat.id]?.folderId)
: { ...local[cat.id], configured: false, status: "default" };
}
for (const [id, state] of Object.entries(reconciled)) {
updatePolicy(id, state);
}
})();
return () => {
cancelled = true;
};
}, []);
/**
* Enable a new policy from the wizard result: persist it to the backend (the
* source of truth), then create the backing folder holding its editable
* automation, and cache the result locally. Throws (surfacing in the wizard)
* if the category is unknown or the backend save fails.
*/
const enablePolicy = useCallback(
async (id: string, result: PolicyWizardResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
// One policy per category, ever: reuse any existing backend record.
const existingBackendId =
loadPolicies()[id]?.backendId ??
(await findBackendId(id).catch(() => undefined));
const backendId = await persistPolicy(
toStoreRequest(id, category.label, result, true, existingBackendId),
);
const folder = await createPolicyFolderForAutomation(
category,
result.automation.id,
);
await updatePolicyFolderSettings(folder.id, result.folder);
updatePolicy(id, {
configured: true,
status: "active",
folderId: folder.id,
backendId,
fieldValues: result.fieldValues,
sources: result.sources,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
});
},
[],
);
/**
* Save edits from the wizard. The workflow automation is updated in place by
* the builder; persist the updated policy to the backend and the folder's
* output/retry settings + the rest of the settings locally.
*/
const savePolicyConfig = useCallback(
async (id: string, result: PolicyWizardResult) => {
const current = loadPolicies()[id];
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
const backendId = await persistPolicy(
toStoreRequest(
id,
category.label,
result,
current?.status !== "paused",
current?.backendId,
),
);
if (current?.folderId) {
await updatePolicyFolderSettings(current.folderId, result.folder);
}
updatePolicy(id, {
backendId,
fieldValues: result.fieldValues,
sources: result.sources,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
});
},
[],
);
/**
* Create-or-update a policy from the locked tool-config page. Unlike the
* wizard path this works straight from the tool `operations` (the config page
* owns the chain): it creates the backing folder + automation on first
* configure, or updates the existing automation's operations on edit, then
* mirrors the whole policy to the backend. One method serves both because a
* preset policy has no separate "create" you're just configuring it.
*/
const commitPolicyConfig = useCallback(
async (id: string, result: PolicyConfigResult) => {
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
if (!category) throw new Error(`Unknown policy category: ${id}`);
const current = loadPolicies()[id];
// One policy per category, ever: reuse the existing backend record (even
// if the local link was lost) so a save never creates a duplicate.
const existingBackendId =
current?.backendId ?? (await findBackendId(id).catch(() => undefined));
let folderId = current?.folderId;
if (folderId) {
await updatePolicyOperations(folderId, result.operations);
} else {
const folder = await createPolicyFolder(category, result.operations);
folderId = folder.id;
}
await updatePolicyFolderSettings(folderId, result.folder);
// The saved automation (with its id) is the lossless round-trip blob.
const automation = await getPolicyAutomation(folderId);
const store: PolicyToStore = {
id: existingBackendId,
categoryId: id,
name: `${category.label} Policy`,
enabled: current?.status !== "paused",
automation: automation ?? {
id: "",
name: `${category.label} Policy`,
operations: result.operations,
createdAt: "",
updatedAt: "",
},
pipelineSteps: result.pipelineSteps,
sources: result.sources,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
fieldValues: result.fieldValues,
folder: result.folder,
};
const backendId = await persistPolicy(store);
updatePolicy(id, {
configured: true,
status: current?.status === "paused" ? "paused" : "active",
folderId,
backendId,
fieldValues: result.fieldValues,
sources: result.sources,
scopeTypes: result.scopeTypes,
reviewerEmail: result.reviewerEmail,
});
},
[],
);
const pausePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) await setPolicyEnabled(current.backendId, false);
if (current?.folderId) await setPolicyFolderPaused(current.folderId, true);
updatePolicy(id, { status: "paused" });
}, []);
const resumePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) await setPolicyEnabled(current.backendId, true);
if (current?.folderId) await setPolicyFolderPaused(current.folderId, false);
updatePolicy(id, { status: "active" });
}, []);
const deletePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) await removePolicy(current.backendId);
if (current?.folderId) await deletePolicyFolder(current.folderId);
resetPolicy(id);
}, []);
/**
* Ensure a configured policy has a backing folder (its editable pipeline),
* creating one from the preset if missing. Returns the folder id.
*/
const ensurePolicyFolder = useCallback(async (id: string) => {
const existing = loadPolicies()[id]?.folderId;
if (existing) return existing;
const catalog = loadPolicyCatalog();
const category = catalog.categories.find((c) => c.id === id);
const config = catalog.configs[id];
if (!category || !config) return undefined;
const folder = await createPolicyFolder(category, config.defaultOperations);
updatePolicy(id, { folderId: folder.id });
return folder.id;
}, []);
// Configuration is open to signed-in users; real per-org gating is a backend
// concern (the mock owner/admin/member permission model has been removed).
const canConfigure = true;
return {
policies,
canConfigure,
enablePolicy,
savePolicyConfig,
commitPolicyConfig,
pausePolicy,
resumePolicy,
deletePolicy,
ensurePolicyFolder,
};
}
@@ -0,0 +1,16 @@
import { useMemo } from "react";
import {
loadPolicyCatalog,
type PolicyCatalog,
} from "@app/services/policyCatalog";
/**
* Policy definitions (categories / fields / sources / doc types) delivered
* through the catalog seam, so components never import the static definitions
* directly. Memoised; when the catalog becomes a backend fetch, this hook is
* where loading/error state would be introduced its consumers already treat
* it as the single source of definitions.
*/
export function usePolicyCatalog(): PolicyCatalog {
return useMemo(() => loadPolicyCatalog(), []);
}
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
/** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */
const ACCENT_VAR: Record<string, string> = {
blue: "var(--color-blue)",
purple: "var(--color-purple)",
green: "var(--color-green)",
amber: "var(--color-amber)",
red: "var(--color-red)",
};
/**
* Distinct policies that have run on each file, keyed by fileId, derived from the
* reactive policy run store. Drives the file sidebar's shield badges. Shadows the
* core stub via the {@code @app/*} alias cascade.
*/
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
const runs = usePolicyRuns();
return useMemo(() => {
const labelById = new Map(
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
);
const byFile = new Map<string, FileItemPolicyRef[]>();
for (const run of runs) {
const name = labelById.get(run.categoryId);
if (!name) continue;
const list = byFile.get(run.fileId) ?? [];
if (!list.some((p) => p.id === run.categoryId)) {
list.push({
id: run.categoryId,
name,
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
});
byFile.set(run.fileId, list);
}
}
return byFile;
}, [runs]);
}
@@ -33,8 +33,9 @@ export function useWatchedFolders(): UseWatchedFoldersReturn {
const refreshFolders = useCallback(async () => {
try {
// Policy-owned folders are managed by Policies, not shown here.
const all = await watchedFolderStorage.getAllFolders();
setFolders(all);
setFolders(all.filter((f) => !f.policyCategoryId));
} catch (error) {
console.error("Failed to load smart folders:", error);
} finally {
@@ -0,0 +1,99 @@
/**
* Client for the backend Policies engine (`/api/v1/policies`). Runs a
* pipeline on the server the "backend automation" path and polls its status.
* Outputs are downloaded via the existing `/api/v1/general/files/{id}` endpoint
* using the file ids in the run view.
*/
import apiClient from "@app/services/apiClient";
import type {
BackendPipelineDefinition,
BackendPolicy,
PolicyRunView,
} from "@app/services/policyPipeline";
interface JobResponse {
async: boolean;
jobId: string;
result: unknown;
}
// --- Policy config persistence (server-side store, JPA-backed) ---
/** Create or update a policy; the backend assigns a blank id and returns it. */
export async function savePolicy(
policy: BackendPolicy,
): Promise<BackendPolicy> {
const res = await apiClient.post<BackendPolicy>("/api/v1/policies", policy);
return res.data;
}
/** List all stored policies. */
export async function listPolicies(): Promise<BackendPolicy[]> {
const res = await apiClient.get<BackendPolicy[]>("/api/v1/policies");
return res.data;
}
/** Fetch a stored policy by id. */
export async function getPolicy(id: string): Promise<BackendPolicy> {
const res = await apiClient.get<BackendPolicy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
return res.data;
}
/** Delete a stored policy by id. */
export async function deletePolicy(id: string): Promise<void> {
await apiClient.delete(`/api/v1/policies/${encodeURIComponent(id)}`);
}
/** Run a stored policy by id on the supplied files; returns the run id. */
export async function runStoredPolicy(
id: string,
files: File[],
): Promise<string> {
const form = new FormData();
for (const file of files) form.append("fileInput", file);
const res = await apiClient.post<JobResponse>(
`/api/v1/policies/${encodeURIComponent(id)}/run`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return res.data.jobId;
}
// --- Ad-hoc pipeline runs (no stored policy) ---
/**
* Run an ad-hoc pipeline on the backend over the given documents. Returns the
* run id; poll {@link getPolicyRun} for status + output file ids.
*/
export async function runPolicyPipeline(
definition: BackendPipelineDefinition,
files: File[],
): Promise<string> {
const form = new FormData();
for (const file of files) form.append("fileInput", file);
form.append("json", JSON.stringify(definition));
const res = await apiClient.post<JobResponse>("/api/v1/policies/run", form, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data.jobId;
}
/** Download a run's output file by id (via the shared general-files endpoint). */
export async function downloadPolicyOutput(fileId: string): Promise<Blob> {
const res = await apiClient.get<Blob>(
`/api/v1/general/files/${encodeURIComponent(fileId)}`,
{ responseType: "blob" },
);
return res.data;
}
/** Current status, step cursor and output files of a run. */
export async function getPolicyRun(runId: string): Promise<PolicyRunView> {
const res = await apiClient.get<PolicyRunView>(
`/api/v1/policies/run/${encodeURIComponent(runId)}`,
);
return res.data;
}
@@ -0,0 +1,95 @@
/**
* Backend source-of-truth layer for Policies. Wraps the raw `policyApi` client +
* the `policyPipeline` mapper into category-shaped operations the hook can use:
* fetch the stored policies (grouped by catalog category), persist one, flip its
* enabled flag, and delete it.
*
* The frontend is category-keyed (one policy per catalog category); the backend
* is a flat list with assigned ids. The bridge is `trigger.options.categoryId`,
* which `policyPipeline` encodes on save and decodes on read.
*/
import * as policyApi from "@app/services/policyApi";
import {
buildBackendPolicy,
fromBackendPolicy,
type DecodedPolicy,
type PolicyToStore,
} from "@app/services/policyPipeline";
import type { PolicyState } from "@app/types/policies";
/**
* Fetch every stored policy and decode it, keyed by its catalog category. If two
* stored policies share a category (shouldn't happen one per category), the
* last one wins; policies with no recognised categoryId are skipped.
*/
export async function fetchPoliciesByCategory(): Promise<
Map<string, DecodedPolicy>
> {
const stored = await policyApi.listPolicies();
const byCategory = new Map<string, DecodedPolicy>();
for (const policy of stored) {
const decoded = fromBackendPolicy(policy);
if (decoded.categoryId) byCategory.set(decoded.categoryId, decoded);
}
return byCategory;
}
/**
* Project a decoded backend policy onto the frontend per-category state. The
* locally-cached `folderId` (the editable-automation link, which the backend
* doesn't track) is preserved by the caller via `localFolderId`.
*/
export function decodedToState(
decoded: DecodedPolicy,
localFolderId: string | undefined,
): PolicyState {
return {
configured: true,
status: decoded.enabled ? "active" : "paused",
sources: decoded.sources,
scopeTypes: decoded.scopeTypes,
reviewerEmail: decoded.reviewerEmail,
fieldValues: decoded.fieldValues,
folderId: localFolderId,
backendId: decoded.id,
// Catalog-category policies are built-in defaults (not deletable).
isDefault: true,
};
}
/**
* The backend id of the stored policy for a category, if one exists. Used to
* enforce one-policy-per-category: a save reuses this id (update) rather than
* creating a duplicate, even if the local cache lost the link.
*/
export async function findBackendId(
categoryId: string,
): Promise<string | undefined> {
const byCategory = await fetchPoliciesByCategory();
return byCategory.get(categoryId)?.id;
}
/** Persist a policy (create or update); returns the backend-assigned id. */
export async function persistPolicy(store: PolicyToStore): Promise<string> {
const saved = await policyApi.savePolicy(buildBackendPolicy(store));
return saved.id;
}
/**
* Flip a stored policy's `enabled` flag (pause/resume) the backend gates
* automatic triggering on it. Reads the current policy so the rest of its config
* is preserved on the round-trip.
*/
export async function setPolicyEnabled(
backendId: string,
enabled: boolean,
): Promise<void> {
const current = await policyApi.getPolicy(backendId);
await policyApi.savePolicy({ ...current, enabled });
}
/** Delete a stored policy by its backend id. */
export async function removePolicy(backendId: string): Promise<void> {
await policyApi.deletePolicy(backendId);
}
@@ -0,0 +1,43 @@
/**
* The catalog of available policy *definitions* the policy types, their
* editable fields, the sources a policy can run over, and the document types
* scope can be narrowed to. This is the single seam through which definitions
* reach the UI: components never import the static definitions directly, they
* read the catalog (via {@link usePolicyCatalog}).
*
* Backed by the static `policyDefinitions` today; swap {@link loadPolicyCatalog}
* for a fetch to move definitions server-side without touching any component.
*/
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
POLICY_SOURCES,
POLICY_DOC_TYPES,
} from "@app/data/policyDefinitions";
import type {
PolicyCategory,
PolicyConfigDef,
PolicySource,
} from "@app/types/policies";
export interface PolicyCatalog {
/** Available policy types, in display order. */
categories: PolicyCategory[];
/** Per-category narrative + editable field definitions, keyed by category id. */
configs: Record<string, PolicyConfigDef>;
/** Sources a policy can run over (wizard step 2). */
sources: PolicySource[];
/** Document types scope can be narrowed to (gated behind classification). */
docTypes: string[];
}
/** Load the policy catalog. Swap this for a backend fetch to go live. */
export function loadPolicyCatalog(): PolicyCatalog {
return {
categories: POLICY_CATEGORIES,
configs: POLICY_CONFIG,
sources: POLICY_SOURCES,
docTypes: POLICY_DOC_TYPES,
};
}
@@ -0,0 +1,85 @@
import "fake-indexeddb/auto";
import { describe, it, expect, beforeEach, vi } from "vitest";
// jsdom's crypto has no 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 {
createPolicyFolder,
getPolicyOperations,
updatePolicyOperations,
setPolicyFolderPaused,
deletePolicyFolder,
} from "@app/services/policyFolders";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import { automationStorage } from "@app/services/automationStorage";
import type { PolicyCategory } from "@app/types/policies";
const category: PolicyCategory = {
id: "security",
label: "Security",
icon: null,
desc: "Detect PII, encrypt, verify.",
};
const steps = [
{ operation: "sanitize", parameters: {} },
{ operation: "addPassword", parameters: {} },
];
describe("policyFolders backing-folder layer", () => {
beforeEach(async () => {
// Clean slate between tests (fake-indexeddb persists within a run).
for (const f of await watchedFolderStorage.getAllFolders()) {
await watchedFolderStorage.deleteFolder(f.id);
}
for (const a of await automationStorage.getAllAutomations()) {
await automationStorage.deleteAutomation(a.id);
}
});
it("creates a folder + automation tagged with the policy category", async () => {
const folder = await createPolicyFolder(category, steps);
expect(folder.policyCategoryId).toBe("security");
expect(folder.automationId).toBeTruthy();
const ops = await getPolicyOperations(folder.id);
expect(ops.map((o) => o.operation)).toEqual(["sanitize", "addPassword"]);
});
it("updates the steps through the backing automation", async () => {
const folder = await createPolicyFolder(category, steps);
await updatePolicyOperations(folder.id, [
{ operation: "compress", parameters: {} },
]);
const ops = await getPolicyOperations(folder.id);
expect(ops.map((o) => o.operation)).toEqual(["compress"]);
});
it("pauses/resumes via the backing folder flag", async () => {
const folder = await createPolicyFolder(category, steps);
await setPolicyFolderPaused(folder.id, true);
expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
true,
);
await setPolicyFolderPaused(folder.id, false);
expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe(
false,
);
});
it("deletes the folder and its automation", async () => {
const folder = await createPolicyFolder(category, steps);
const automationId = folder.automationId;
await deletePolicyFolder(folder.id);
expect(await watchedFolderStorage.getFolder(folder.id)).toBeNull();
expect(await automationStorage.getAutomation(automationId)).toBeNull();
});
});
@@ -0,0 +1,137 @@
/**
* Backing-folder layer for Policies. A configured policy's folder trigger,
* editable steps, output and run-state all live in a Watched Folders
* {@link WatchedFolder} (+ its {@link AutomationConfig}) the policy reuses the
* Watched Folders engine rather than re-implementing execution. This module is
* the seam that creates and manages that backing record.
*
* The folder is tagged with `policyCategoryId` so the Watched Folders UI can
* filter it out (it's owned by Policies). The backing automation also rides
* along to the backend (in the saved policy's output.options) for round-trip;
* this folder remains the locally-editable copy.
*/
import { automationStorage } from "@app/services/automationStorage";
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
import type { WatchedFolder } from "@app/types/watchedFolders";
import type { PolicyCategory, PolicyFolderSettings } from "@app/types/policies";
/** Folder icon (a name string) used for each policy category's backing folder. */
const CATEGORY_FOLDER_ICON: Record<string, string> = {
ingestion: "StorageIcon",
security: "SecurityIcon",
compliance: "CheckIcon",
routing: "SwapHorizIcon",
retention: "StorageIcon",
};
const POLICY_FOLDER_ACCENT = "#3b82f6";
/**
* Create the backing folder for a policy: persist an automation from the given
* steps, then a WatchedFolder (the folder trigger) referencing it, tagged with
* the policy's category id. Returns the created folder.
*/
export async function createPolicyFolder(
category: PolicyCategory,
operations: AutomationOperation[],
): Promise<WatchedFolder> {
const automation = await automationStorage.saveAutomation({
name: `${category.label} Policy`,
description: `Pipeline for the ${category.label} policy`,
operations,
});
return watchedFolderStorage.createFolder({
name: `${category.label} Policy`,
description: category.desc,
automationId: automation.id,
icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
accentColor: POLICY_FOLDER_ACCENT,
policyCategoryId: category.id,
inputSource: "idb",
});
}
/**
* Create the backing folder for a policy from an *already-saved* automation
* (e.g. one the workflow builder just created). Pairs with the wizard, where
* AutomationCreation persists the automation and we link a folder to it.
*/
export async function createPolicyFolderForAutomation(
category: PolicyCategory,
automationId: string,
): Promise<WatchedFolder> {
return watchedFolderStorage.createFolder({
name: `${category.label} Policy`,
description: category.desc,
automationId,
icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon",
accentColor: POLICY_FOLDER_ACCENT,
policyCategoryId: category.id,
inputSource: "idb",
});
}
/** The policy's current steps, resolved through its backing folder's automation. */
export async function getPolicyOperations(
folderId: string,
): Promise<AutomationOperation[]> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return [];
const automation = await automationStorage.getAutomation(folder.automationId);
return automation?.operations ?? [];
}
/** The policy's backing automation (its editable pipeline), via its folder. */
export async function getPolicyAutomation(
folderId: string,
): Promise<AutomationConfig | null> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return null;
return automationStorage.getAutomation(folder.automationId);
}
/** Replace the policy's steps by updating its backing automation. */
export async function updatePolicyOperations(
folderId: string,
operations: AutomationOperation[],
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
const automation = await automationStorage.getAutomation(folder.automationId);
if (!automation) return;
await automationStorage.updateAutomation({ ...automation, operations });
}
/** Apply output + retry settings to the policy's backing folder. */
export async function updatePolicyFolderSettings(
folderId: string,
settings: PolicyFolderSettings,
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
await watchedFolderStorage.updateFolder({ ...folder, ...settings });
}
/** Pause/resume the policy by toggling its backing folder's paused flag. */
export async function setPolicyFolderPaused(
folderId: string,
paused: boolean,
): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (!folder) return;
await watchedFolderStorage.updateFolder({ ...folder, isPaused: paused });
}
/** Delete the policy's backing folder and its automation. */
export async function deletePolicyFolder(folderId: string): Promise<void> {
const folder = await watchedFolderStorage.getFolder(folderId);
if (folder) {
await automationStorage.deleteAutomation(folder.automationId);
}
await watchedFolderStorage.deleteFolder(folderId);
}
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
runsToActivity,
runsToStats,
policyActiveFor,
} from "@app/services/policyLiveData";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
function run(over: Partial<PolicyRunRecord>): PolicyRunRecord {
return {
runId: "r1",
categoryId: "security",
fileId: "f1",
fileName: "f.pdf",
fileSize: 0,
status: "COMPLETED",
outputs: [],
error: null,
startedAt: Date.now(),
...over,
};
}
const HOUR = 3_600_000;
describe("runsToActivity", () => {
it("maps completed/running/failed runs to activity rows", () => {
const activity = runsToActivity([
run({ runId: "a", fileName: "fresh.pdf", status: "RUNNING" }),
run({
runId: "b",
fileName: "contract.pdf",
status: "COMPLETED",
fileSize: 2_100_000,
startedAt: Date.now() - HOUR,
}),
run({
runId: "c",
fileName: "bad.pdf",
status: "FAILED",
error: "Step 2 failed",
}),
]);
expect(activity).toHaveLength(3);
expect(activity[0]).toMatchObject({
doc: "fresh.pdf",
status: "processing",
action: "Enforcing…",
});
expect(activity[1]).toMatchObject({
doc: "contract.pdf",
status: "enforced",
});
expect(activity[1].action).toContain("2.0 MB");
expect(activity[2]).toMatchObject({
doc: "bad.pdf",
status: "flagged",
action: "Step 2 failed",
});
});
});
describe("runsToStats", () => {
it("counts + sizes only the completed runs", () => {
const stats = runsToStats(
[
run({ runId: "a", status: "COMPLETED", fileSize: 2_100_000 }),
run({ runId: "b", status: "COMPLETED", fileSize: 900_000 }),
run({ runId: "c", status: "RUNNING", fileSize: 5_000_000 }),
],
undefined,
);
expect(stats.enforced).toBe(2);
expect(stats.dataProcessed).toBe("2.9 MB");
expect(stats.activeFor).toBe("Today");
});
it("derives activeFor from the backing folder's creation time", () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString();
expect(runsToStats([], fiveDaysAgo).activeFor).toBe("5d");
});
});
describe("policyActiveFor", () => {
it("returns 'Today' for a just-activated policy", () => {
expect(policyActiveFor(new Date().toISOString())).toBe("Today");
});
it("returns 'Today' when there's no backing folder", () => {
expect(policyActiveFor(undefined)).toBe("Today");
});
it("reports whole-day duration since activation", () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString();
expect(policyActiveFor(fiveDaysAgo)).toBe("5d");
});
});
@@ -0,0 +1,100 @@
/**
* Maps real backend policy runs (from policyRunStore) into the detail view's
* activity feed + summary stats. Runs are produced by the auto-run controller
* firing `/api/v1/policies/{id}/run` on every uploaded file, so the feed is the
* policy's actual enforcement history not a cosmetic file listing.
*/
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
/** Relative "Nm/Nh ago" for an activity timestamp (epoch ms). */
function relativeTime(ts: number): string {
if (!ts) return "—";
const mins = Math.floor((Date.now() - ts) / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return days === 1 ? "Yesterday" : `${days}d ago`;
}
/** Duration since a timestamp, e.g. "18d" / "5h" (no "ago"). */
function durationSince(ts: number): string {
const ms = Date.now() - ts;
const days = Math.floor(ms / 86400000);
if (days >= 1) return `${days}d`;
const hrs = Math.floor(ms / 3600000);
return hrs >= 1 ? `${hrs}h` : "Today";
}
/**
* Human byte size, e.g. "2.1 MB". Intentionally NOT core `formatFileSize`:
* that one renders 2 decimals (`2.13 MB`), whereas the policy summary wants
* the quieter whole-number / single-decimal form used across this surface.
*/
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
/** Map a run's lifecycle status to an activity row's display status. */
function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] {
if (run.status === "COMPLETED") return "enforced";
if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged";
return "processing";
}
function activityAction(run: PolicyRunRecord): string {
switch (activityStatus(run)) {
case "enforced":
return `${formatBytes(run.fileSize)} • enforced`;
case "flagged":
return run.error ?? "Enforcement failed";
default:
return "Enforcing…";
}
}
/** Build the detail view's activity feed from a category's runs (newest first). */
export function runsToActivity(runs: PolicyRunRecord[]): PolicyActivityItem[] {
return runs.map((run) => ({
doc: run.fileName,
action: activityAction(run),
time: relativeTime(run.startedAt),
status: activityStatus(run),
runId: run.runId,
fileId: run.fileId,
}));
}
/** Build the summary stats from a category's runs. */
export function runsToStats(
runs: PolicyRunRecord[],
folderCreatedAt: string | undefined,
): PolicyStats {
const enforced = runs.filter((r) => r.status === "COMPLETED");
const bytes = enforced.reduce((sum, r) => sum + (r.fileSize ?? 0), 0);
return {
enforced: enforced.length,
dataProcessed: formatBytes(bytes),
activeFor: policyActiveFor(folderCreatedAt),
};
}
/**
* How long a policy has been active from its backing folder's creation time
* (when it was enabled), or "Today" when there's no folder yet.
*/
export function policyActiveFor(folderCreatedAt: string | undefined): string {
if (!folderCreatedAt) return "Today";
return durationSince(new Date(folderCreatedAt).getTime());
}
@@ -0,0 +1,157 @@
import { describe, it, expect } from "vitest";
import {
buildPipelineDefinition,
buildBackendPolicy,
fromBackendPolicy,
} from "@app/services/policyPipeline";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
// Minimal registry: a static-endpoint tool and a function-endpoint tool.
const registry = {
compress: { operationConfig: { endpoint: "/api/v1/misc/compress-pdf" } },
rotate: {
operationConfig: {
endpoint: (p: Record<string, unknown>) =>
`/api/v1/general/rotate-pdf?angle=${p.angle}`,
},
},
} as unknown as Partial<ToolRegistry>;
describe("buildPipelineDefinition", () => {
it("maps frontend operations to backend endpoint steps", () => {
const { definition, unresolved } = buildPipelineDefinition(
{
name: "Secure Ingestion",
operations: [
{ operation: "compress", parameters: {} },
{ operation: "rotate", parameters: { angle: 90 } },
],
},
registry,
);
expect(unresolved).toEqual([]);
expect(definition.name).toBe("Secure Ingestion");
expect(definition.output).toEqual({ type: "inline", options: {} });
expect(definition.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
{
operation: "/api/v1/general/rotate-pdf?angle=90",
parameters: { angle: 90 },
},
]);
});
it("drops + reports operations with no resolvable endpoint", () => {
const { definition, unresolved } = buildPipelineDefinition(
{
name: "X",
operations: [
{ operation: "compress", parameters: {} },
{ operation: "notARealTool", parameters: {} },
],
},
registry,
);
expect(unresolved).toEqual(["notARealTool"]);
expect(definition.steps).toHaveLength(1);
});
it("runs each tool's buildFormData so stored params match its endpoint", () => {
// A redact-like tool whose UI param (wordsToRedact[]) is transformed into
// the endpoint's field (listOfText) by buildFormData — the "marry up".
const redactish = {
redact: {
operationConfig: {
endpoint: () => "/api/v1/security/auto-redact",
buildFormData: (
p: { wordsToRedact?: string[]; useRegex?: boolean },
file: File,
) => {
const fd = new FormData();
fd.append("fileInput", file);
fd.append("listOfText", (p.wordsToRedact ?? []).join("\n"));
fd.append("useRegex", String(p.useRegex ?? false));
return fd;
},
},
},
} as unknown as Partial<ToolRegistry>;
const { definition } = buildPipelineDefinition(
{
name: "Security",
operations: [
{
operation: "redact",
parameters: { wordsToRedact: ["SSN", "Account"], useRegex: true },
},
],
},
redactish,
);
// The document field is dropped; the UI param became the endpoint field.
expect(definition.steps[0]).toEqual({
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "SSN\nAccount", useRegex: "true" },
});
});
});
const samplePolicy = {
categoryId: "security",
name: "Security",
enabled: true,
automation: {
id: "auto-1",
name: "Security",
operations: [{ operation: "compress", parameters: {} }],
createdAt: "",
updatedAt: "",
},
pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }],
sources: ["editor"],
scopeTypes: ["Contracts"],
reviewerEmail: "[email protected]",
fieldValues: { minConfidence: "80%" },
folder: {
outputMode: "new_version" as const,
outputName: "secured",
outputNamePosition: "suffix" as const,
maxRetries: 2,
retryDelayMinutes: 10,
},
};
describe("buildBackendPolicy", () => {
it("maps a frontend policy to the backend Policy shape", () => {
const policy = buildBackendPolicy(samplePolicy);
expect(policy.id).toBe(""); // blank → backend assigns
expect(policy.name).toBe("Security");
expect(policy.enabled).toBe(true);
expect(policy.steps).toEqual([
{ operation: "/api/v1/misc/compress-pdf", parameters: {} },
]);
// Extras ride in options.
expect(policy.trigger.options.categoryId).toBe("security");
expect(policy.trigger.options.reviewerEmail).toBe("[email protected]");
expect(policy.output.options.maxRetries).toBe(2);
});
it("round-trips losslessly through fromBackendPolicy", () => {
const policy = buildBackendPolicy(samplePolicy);
const decoded = fromBackendPolicy({ ...policy, id: "p1" });
expect(decoded.id).toBe("p1");
expect(decoded.categoryId).toBe("security");
expect(decoded.enabled).toBe(true);
expect(decoded.sources).toEqual(["editor"]);
expect(decoded.scopeTypes).toEqual(["Contracts"]);
expect(decoded.reviewerEmail).toBe("[email protected]");
expect(decoded.fieldValues).toEqual({ minConfidence: "80%" });
expect(decoded.folder).toEqual(samplePolicy.folder);
expect(decoded.automation?.operations).toEqual(
samplePolicy.automation.operations,
);
});
});
@@ -0,0 +1,291 @@
/**
* Bridge between the frontend automation model and the backend Policies engine.
* "Backend automation" = send the whole pipeline + files to
* `/api/v1/policies/run` and let the server orchestrate the steps, instead of
* the browser running them one-by-one via executeAutomationSequence.
*
* The backend `PipelineStep.operation` is a tool endpoint *path*
* (e.g. `/api/v1/misc/compress-pdf`) exactly the `operationConfig.endpoint`
* the frontend tool registry already carries for client-side execution. This
* module maps a frontend AutomationConfig to the backend's PipelineDefinition
* using that registry.
*/
import type { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { PolicyFolderSettings } from "@app/types/policies";
/** A single backend pipeline step: a tool endpoint path + its scalar params. */
export interface BackendPipelineStep {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}
/** Where the run's outputs are delivered. "inline" = return for download. */
export interface BackendOutputSpec {
type: string;
options: Record<string, unknown>;
}
/** The engine-level pipeline the `/run` endpoint accepts (as JSON). */
export interface BackendPipelineDefinition {
name: string;
steps: BackendPipelineStep[];
output: BackendOutputSpec;
}
/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */
export interface BackendTriggerConfig {
type: string;
options: Record<string, unknown>;
}
/** A stored, owned policy on the backend (mirrors the Java `Policy` record). */
export interface BackendPolicy {
/** Blank on create — the backend assigns an id and returns it. */
id: string;
name: string;
owner: string;
/** Gates automatic triggering; an explicit run ignores it. */
enabled: boolean;
trigger: BackendTriggerConfig;
steps: BackendPipelineStep[];
output: BackendOutputSpec;
}
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
export type PolicyRunStatus =
| "PENDING"
| "RUNNING"
| "WAITING_FOR_INPUT"
| "COMPLETED"
| "FAILED"
| "CANCELLED";
export interface BackendResultFile {
fileId: string;
fileName: string;
}
/** Read-only view returned by the run status endpoint (mirrors PolicyRunView). */
export interface PolicyRunView {
runId: string;
status: PolicyRunStatus;
currentStep: number;
stepCount: number;
error: string | null;
outputs: BackendResultFile[];
}
/** Resolve a frontend operation id to its backend tool endpoint path. */
function resolveEndpoint(
operation: string,
parameters: Record<string, unknown>,
toolRegistry: Partial<ToolRegistry>,
): string | null {
const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
const endpoint = config?.endpoint;
if (!endpoint) return null;
const resolved =
typeof endpoint === "function" ? endpoint(parameters) : endpoint;
return resolved ?? null;
}
/**
* Convert a tool's UI parameters into the exact scalar form-fields its backend
* endpoint expects, by running the same `buildFormData` the client-side runner
* uses (the one source of truth for the request shape) and keeping its non-file
* fields. This is what makes the stored steps "marry up" with the engine: e.g.
* redact's `wordsToRedact: string[]` becomes the `listOfText` string the
* /auto-redact endpoint reads. Falls back to the raw params if the tool has no
* transform (or it throws), so tools without one are unaffected.
*/
function toApiParameters(
config: ToolRegistry[keyof ToolRegistry]["operationConfig"] | undefined,
parameters: Record<string, unknown>,
): Record<string, unknown> {
const build = config?.buildFormData;
if (typeof build !== "function") return parameters;
const dummy = new File([], "input.pdf", { type: "application/pdf" });
// buildFormData takes a File (single-file tools) or File[] (multi) — try both.
for (const fileArg of [dummy, [dummy]]) {
try {
const formData = build(parameters, fileArg as never);
const out: Record<string, unknown> = {};
// Keep scalar fields; skip File entries (the document(s) the engine feeds
// separately, and any supporting-file blobs).
formData.forEach((value, key) => {
if (typeof value === "string") out[key] = value;
});
return out;
} catch {
// Wrong file-arg shape for this tool — try the other, then give up.
}
}
return parameters;
}
/**
* Map a frontend automation to the backend pipeline definition. Steps whose
* endpoint can't be resolved from the registry are dropped (and reported), so
* the backend never receives an unrunnable operation id.
*/
export function buildPipelineDefinition(
automation: Pick<AutomationConfig, "name" | "operations">,
toolRegistry: Partial<ToolRegistry>,
): { definition: BackendPipelineDefinition; unresolved: string[] } {
const unresolved: string[] = [];
const steps: BackendPipelineStep[] = [];
for (const op of automation.operations) {
const parameters = (op.parameters ?? {}) as Record<string, unknown>;
const endpoint = resolveEndpoint(op.operation, parameters, toolRegistry);
if (!endpoint) {
unresolved.push(op.operation);
continue;
}
const config =
toolRegistry[op.operation as keyof ToolRegistry]?.operationConfig;
steps.push({
operation: endpoint,
parameters: toApiParameters(config, parameters),
});
}
return {
definition: {
name: automation.name,
steps,
output: { type: "inline", options: {} },
},
unresolved,
};
}
/** A frontend policy ready to persist on the backend (the full settings set). */
export interface PolicyToStore {
/** Existing backend id (blank/omitted → create). */
id?: string;
/** The frontend catalog category this policy belongs to (1 policy per category). */
categoryId: string;
name: string;
/** Active (enabled) vs paused/off. */
enabled: boolean;
/** Full frontend automation, stashed for a lossless UI round-trip. */
automation: AutomationConfig;
/**
* The engine-runnable steps (endpoint paths), pre-built from `automation` via
* the tool registry by the caller that has it (the wizard). The store layer
* has no registry, so it receives these ready-made.
*/
pipelineSteps: BackendPipelineStep[];
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
folder: PolicyFolderSettings;
}
/** The decoded policy read back from the backend. */
export interface DecodedPolicy {
id: string;
/** The catalog category this policy maps to (from trigger.options.categoryId). */
categoryId: string;
name: string;
enabled: boolean;
/** Null if the stored policy carried no automation blob. */
automation: AutomationConfig | null;
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
fieldValues: Record<string, boolean | string | string[]>;
folder: PolicyFolderSettings;
}
const DEFAULT_FOLDER: PolicyFolderSettings = {
outputMode: "new_file",
outputName: "",
outputNamePosition: "prefix",
maxRetries: 3,
retryDelayMinutes: 5,
};
/**
* Map a frontend policy to the backend {@link BackendPolicy} for persistence.
* The backend models only name/enabled/trigger/steps/output, so the policy-level
* extras (categoryId, sources, scope, reviewer, fields) ride in `trigger.options`
* and the output + retry settings in `output.options`; the full frontend
* automation is stashed in `output.options.automation` for a lossless UI
* round-trip (while `steps` carries the endpoint-mapped pipeline the engine
* runs, pre-built by the caller).
*/
export function buildBackendPolicy(input: PolicyToStore): BackendPolicy {
return {
id: input.id ?? "",
name: input.name,
owner: "",
enabled: input.enabled,
trigger: {
type: "folder",
options: {
categoryId: input.categoryId,
sources: input.sources,
scopeTypes: input.scopeTypes,
reviewerEmail: input.reviewerEmail,
fieldValues: input.fieldValues,
},
},
steps: input.pipelineSteps,
output: {
type: "inline",
options: {
mode: input.folder.outputMode,
name: input.folder.outputName,
position: input.folder.outputNamePosition,
maxRetries: input.folder.maxRetries,
retryDelayMinutes: input.folder.retryDelayMinutes,
automation: input.automation,
},
},
};
}
/** Decode a stored backend policy back into the frontend settings. */
export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy {
const trigger = policy.trigger.options;
const output = policy.output.options;
const str = (v: unknown, fallback = "") =>
typeof v === "string" ? v : fallback;
const num = (v: unknown, fallback: number) =>
typeof v === "number" ? v : fallback;
return {
id: policy.id,
categoryId: str(trigger.categoryId),
name: policy.name,
enabled: policy.enabled,
automation: (output.automation as AutomationConfig | undefined) ?? null,
sources: Array.isArray(trigger.sources)
? (trigger.sources as string[])
: [],
scopeTypes: Array.isArray(trigger.scopeTypes)
? (trigger.scopeTypes as string[])
: [],
reviewerEmail: str(trigger.reviewerEmail),
fieldValues:
(trigger.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {},
folder: {
outputMode: output.mode === "new_version" ? "new_version" : "new_file",
outputName: str(output.name),
outputNamePosition:
output.position === "suffix"
? "suffix"
: output.position === "auto-number"
? "auto-number"
: "prefix",
maxRetries: num(output.maxRetries, DEFAULT_FOLDER.maxRetries),
retryDelayMinutes: num(
output.retryDelayMinutes,
DEFAULT_FOLDER.retryDelayMinutes,
),
},
};
}
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
loadPolicies,
updatePolicy,
resetPolicy,
onPoliciesChange,
} from "@app/services/policyStorage";
describe("policyStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults every category to unconfigured (backend is the source of truth)", () => {
const p = loadPolicies();
expect(p.ingestion.configured).toBe(false);
expect(p.ingestion.status).toBe("default");
expect(p.security.configured).toBe(false);
expect(p.security.status).toBe("default");
expect(p.retention.configured).toBe(false);
});
it("persists an update and reflects it on reload", () => {
updatePolicy("security", { configured: true, status: "active" });
const reloaded = loadPolicies();
expect(reloaded.security.configured).toBe(true);
expect(reloaded.security.status).toBe("active");
// Other categories untouched.
expect(reloaded.retention.configured).toBe(false);
});
it("merges partial field-value updates without clobbering siblings", () => {
updatePolicy("security", { fieldValues: { detectPII: false } });
updatePolicy("security", { reviewerEmail: "[email protected]" });
const p = loadPolicies();
expect(p.security.fieldValues).toEqual({ detectPII: false });
expect(p.security.reviewerEmail).toBe("[email protected]");
});
it("resetPolicy reverts a category to unconfigured default", () => {
updatePolicy("compliance", {
configured: true,
status: "active",
reviewerEmail: "[email protected]",
});
resetPolicy("compliance");
const p = loadPolicies();
expect(p.compliance.configured).toBe(false);
expect(p.compliance.status).toBe("default");
});
it("heals missing categories from corrupt/partial storage", () => {
localStorage.setItem(
"stirling-policies-state",
JSON.stringify({ ingestion: { configured: true, status: "active" } }),
);
const p = loadPolicies();
// Missing category gets a default rather than being undefined.
expect(p.routing).toBeDefined();
expect(p.routing.configured).toBe(false);
});
it("fires a change event on update", () => {
const cb = vi.fn();
const off = onPoliciesChange(cb);
updatePolicy("routing", { status: "paused" });
expect(cb).toHaveBeenCalledTimes(1);
off();
updatePolicy("routing", { status: "active" });
expect(cb).toHaveBeenCalledTimes(1); // not called after unsubscribe
});
});
@@ -0,0 +1,111 @@
/**
* Local cache + offline fallback for Policies the backend (/api/v1/policies)
* is the source of truth. Holds per-category state (configured/active/paused,
* sources, scope, reviewer, field overrides) and broadcasts changes so hooks
* re-render. Mirrors the change-event pattern of the automation/folder stores.
*/
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import type { PoliciesByCategory, PolicyState } from "@app/types/policies";
const STORAGE_KEY = "stirling-policies-state";
export const POLICIES_CHANGE_EVENT = "stirling:policies-changed";
function defaultState(): PolicyState {
// Unconfigured by default. The backend is the source of truth for what's
// actually configured + active; this is just the empty local-cache shape.
return {
configured: false,
status: "default",
sources: ["editor"],
scopeTypes: [],
// Empty by default; the wizard defaults the reviewer to the signed-in user.
reviewerEmail: "",
fieldValues: {},
// Every catalog category is a shipped, built-in policy → default (not
// deletable). User-created policies (later) will set this false.
isDefault: true,
};
}
/** An obsolete reviewer email from earlier development, scrubbed from persisted
* state on read so it can re-default to the real signed-in user. */
const STALE_REVIEWER_EMAIL = "[email protected]";
/** Read the full policy state, seeding + healing any missing categories. */
export function loadPolicies(): PoliciesByCategory {
let parsed: Partial<PoliciesByCategory> = {};
try {
const raw =
typeof localStorage !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (raw) parsed = JSON.parse(raw) as Partial<PoliciesByCategory>;
} catch {
// Corrupt/unavailable storage — fall back to seed.
}
// Always reconcile against the current category list so a newly-added
// category gets a default rather than being undefined.
const out: PoliciesByCategory = {};
for (const cat of loadPolicyCatalog().categories) {
const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
// Migration: clear the obsolete persisted reviewer email so it re-defaults
// to the real signed-in user.
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
merged.reviewerEmail = "";
out[cat.id] = merged;
}
return out;
}
function persist(state: PoliciesByCategory): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
} catch {
// Best-effort; ignore quota/availability failures.
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(POLICIES_CHANGE_EVENT));
}
}
/** Merge a partial update into one category's state and persist. */
export function updatePolicy(
categoryId: string,
patch: Partial<PolicyState>,
): PoliciesByCategory {
const current = loadPolicies();
const next: PoliciesByCategory = {
...current,
// Fall back to defaults so a not-yet-seeded category id still yields a
// complete PolicyState rather than a partial.
[categoryId]: {
...defaultState(),
...current[categoryId],
...patch,
},
};
persist(next);
return next;
}
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
...defaultState(),
configured: false,
status: "default",
// Drop the backing-folder + backend links (the caller deletes those).
folderId: undefined,
backendId: undefined,
});
}
/** Subscribe to policy-state changes (same-tab). Returns an unsubscribe fn. */
export function onPoliciesChange(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(POLICIES_CHANGE_EVENT, cb);
return () => window.removeEventListener(POLICIES_CHANGE_EVENT, cb);
}
@@ -0,0 +1,211 @@
/**
* Types for Policies a proprietary, automation-backed enforcement feature.
*
* A Policy is conceptually like a Watch Folder (a configured automation that
* runs over documents) but backend-driven and triggered by *sources/events*
* (editor save/export, device sweeps, cloud connectors) rather than just a
* folder. Per-policy state is persisted locally (localStorage) today; server
* persistence and real enforcement land in a follow-up. Activity + stats shown
* in the detail view are derived live from the user's real uploaded files.
*/
import type { ReactNode } from "react";
import type {
AutomationConfig,
AutomationOperation,
} from "@app/types/automation";
/** Lifecycle status of a policy category for the current user/org. */
export type PolicyStatus = "default" | "active" | "paused";
/** Derived display status for a row/detail (treats a spend-limit hit as paused). */
export type PolicyRowStatus = "active" | "paused" | "setup";
/** A configurable field within a policy's settings. */
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
export interface PolicyField {
label: string;
key: string;
type: PolicyFieldType;
/** Current value: boolean (toggle), string (select/text), string[] (chips). */
value: boolean | string | string[];
/** Options for select/chips. */
options?: string[];
}
/** Static definition of a policy category (the "what it does"). */
export interface PolicyCategory {
id: string;
label: string;
icon: ReactNode;
/** Long description shown in the setup wizard. */
desc: string;
/**
* This category provides document classification, so it's the target of the
* setup wizard's "Set up Classification" action. Data-driven replacement for
* the hardcoded `selectPolicy("ingestion")` call site.
*/
providesClassification?: boolean;
/**
* Not yet available shown as a locked "Coming soon" row that can't be opened
* or configured. Only Security is live today.
*/
comingSoon?: boolean;
}
/**
* The three-up summary stats shown at the foot of a configured policy's detail,
* derived live from the user's uploaded files (see policyLiveData).
*/
export interface PolicyStats {
/** Documents enforced (rendered with toLocaleString). */
enforced: number;
/** Human-formatted data-processed figure, e.g. "2.3 GB". */
dataProcessed: string;
/** Human-formatted active-for figure, e.g. "12d", or "—" when idle. */
activeFor: string;
}
/** The narrative + field configuration backing a category. */
export interface PolicyConfigDef {
/** One-line summary of what the policy enforces. */
summary: string;
/** Pipeline-like rule chips shown in the "Enforces" section. */
rules: string[];
/** Human label for the scope this policy applies to. */
scopeLabel: string;
/** Editable settings fields. */
fields: PolicyField[];
/**
* The preset pipeline this category seeds a new policy with the real,
* editable tool steps (same shape as the backend's `PipelineStep` and the
* Watch Folders automation `operations`). Configuring a policy starts from
* these and the user can edit them.
*/
defaultOperations: AutomationOperation[];
}
/** A document a source can ingest, used in the wizard's "Sources" step. */
export interface PolicySource {
id: string;
label: string;
desc: string;
icon: ReactNode;
}
/** An entry in a policy's recent-activity feed (derived from real uploads). */
export interface PolicyActivityItem {
/** Document the policy acted on. */
doc: string;
/** What the policy did, e.g. "Classified as Contract • 3 tables extracted". */
action: string;
/** Relative timestamp, e.g. "2h ago". */
time: string;
/**
* "enforced" (clean green), "flagged" (needs review, amber), or "processing"
* (in progress enforcement currently running, blue).
*/
status: "enforced" | "flagged" | "processing";
/** The backend run this row reflects — used to offer Retry on a failed run. */
runId?: string;
/** The file the run acted on — needed to re-run it on Retry. */
fileId?: string;
}
/** Per-category runtime state held in the local cache. */
export interface PolicyState {
configured: boolean;
status: PolicyStatus;
/** Selected sources (ids from POLICY_SOURCES). */
sources: string[];
/** When non-empty, narrows the policy to these document types. */
scopeTypes: string[];
/** Email that low-confidence enforcements are routed to. */
reviewerEmail: string;
/** Saved field values, keyed by field key (overrides the definition default). */
fieldValues: Record<string, boolean | string | string[]>;
/**
* The backing folder-trigger record (a Watched Folders `WatchedFolder`) that
* holds this policy's editable steps (its automation), output config and run
* state. Present once the policy is configured. The folder trigger reuses the
* Watched Folders engine; this is the link to it. Maps to the backend's
* `Policy.trigger` (folder) + `steps` + `output`.
*/
folderId?: string;
/**
* Id of this policy's record on the backend (the source of truth). Present once
* it has been persisted server-side; used to update/delete/run it.
*/
backendId?: string;
/**
* A built-in policy (one of the shipped catalog categories) rather than a
* user-created one. Default policies are configurable but NOT deletable the
* Delete action is hidden for them (it returns for custom policies later).
*/
isDefault?: boolean;
}
export type PoliciesByCategory = Record<string, PolicyState>;
/**
* Output + retry settings applied by the Watch Folders engine to a policy's
* backing folder (the real, working settings reused from the folder setup).
*/
export interface PolicyFolderSettings {
outputMode: "new_file" | "new_version";
outputName: string;
outputNamePosition: "prefix" | "suffix" | "auto-number";
maxRetries: number;
retryDelayMinutes: number;
}
/** Everything the shared policy wizard collects, handed back on submit. */
export interface PolicyWizardResult {
/** The saved workflow automation (created on setup, updated in place on edit). */
automation: AutomationConfig;
fieldValues: Record<string, boolean | string | string[]>;
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
/** Output + retry settings for the backing folder. */
folder: PolicyFolderSettings;
/**
* Backend pipeline steps (each `operation` is a tool ENDPOINT path), built
* from the workflow via the tool registry in the wizard's Workflow step the
* hook persists these to the backend without needing the registry itself.
* Structurally matches policyPipeline's `BackendPipelineStep` (inlined here to
* avoid a typesservices import cycle).
*/
pipelineSteps: {
operation: string;
parameters: Record<string, unknown>;
fileParameters?: Record<string, string>;
}[];
/** Operation ids whose endpoint couldn't be resolved (dropped from steps). */
unresolvedOps: string[];
}
/**
* What the locked tool-config page hands back on save. Unlike the wizard's
* result it carries the tool `operations` directly (the page owns a fixed,
* configure-only chain there's no separate saved automation), plus the
* endpoint-mapped pipeline steps. Used for both first-time configure and edits
* of a preset policy.
*/
export interface PolicyConfigResult {
/** The enabled tools (in order) as automation operations. */
operations: AutomationOperation[];
/** Endpoint-mapped backend steps built from `operations` via the registry. */
pipelineSteps: PolicyWizardResult["pipelineSteps"];
/** Operation ids whose endpoint couldn't be resolved (dropped from steps). */
unresolvedOps: string[];
fieldValues: Record<string, boolean | string | string[]>;
sources: string[];
scopeTypes: string[];
reviewerEmail: string;
folder: PolicyFolderSettings;
}
/** Which sub-view of a configured policy's detail panel is showing. */
export type PolicyDetailView = "detail" | "settings";
+9
View File
@@ -203,6 +203,15 @@ export default defineConfig(async ({ mode }) => {
}),
compressStaticCopyPlugin(),
],
resolve: {
// Global alias so @shared resolves for ALL importers — including the
// shared components' own `@shared/components/X.css` self-imports, which
// live outside the editor tsconfig scope and so aren't rewritten by
// vite-tsconfig-paths. Required for the editor to consume SUI components.
alias: {
"@shared": path.resolve(__dirname, "../shared"),
},
},
server: {
host: true,
// make sure this port matches the devUrl port in tauri.conf.json file
+21
View File
@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "node:path";
// Global @shared alias so SUI components (and their own `@shared/*` self-imports,
// which live outside the editor tsconfig scope) resolve under test — mirrors the
// resolve.alias in vite.config.ts.
const sharedDir = path.resolve(__dirname, "../shared");
export default defineConfig({
test: {
@@ -42,6 +48,9 @@ export default defineConfig({
projects: ["./tsconfig.core.vite.json"],
}),
],
resolve: {
alias: { "@shared": sharedDir },
},
esbuild: {
target: "es2020",
},
@@ -60,6 +69,9 @@ export default defineConfig({
projects: ["./tsconfig.proprietary.vite.json"],
}),
],
resolve: {
alias: { "@shared": sharedDir },
},
esbuild: {
target: "es2020",
},
@@ -78,6 +90,9 @@ export default defineConfig({
projects: ["./tsconfig.desktop.vite.json"],
}),
],
resolve: {
alias: { "@shared": sharedDir },
},
esbuild: {
target: "es2020",
},
@@ -96,6 +111,9 @@ export default defineConfig({
projects: ["./tsconfig.saas.vite.json"],
}),
],
resolve: {
alias: { "@shared": sharedDir },
},
esbuild: {
target: "es2020",
},
@@ -114,6 +132,9 @@ export default defineConfig({
projects: ["./tsconfig.prototypes.vite.json"],
}),
],
resolve: {
alias: { "@shared": sharedDir },
},
esbuild: {
target: "es2020",
},
+6
View File
@@ -75,6 +75,12 @@
display: block;
}
.sui-check__icon {
display: inline-flex;
align-items: center;
color: var(--color-text-4);
flex-shrink: 0;
}
.sui-check__text {
display: flex;
flex-direction: column;
+8 -1
View File
@@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit<
> {
label?: ReactNode;
description?: ReactNode;
/** Optional glyph shown between the box and the label text. */
leadingIcon?: ReactNode;
/** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
indeterminate?: boolean;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, indeterminate, className, id, ...rest },
{ label, description, leadingIcon, indeterminate, className, id, ...rest },
ref,
) {
return (
@@ -57,6 +59,11 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
</svg>
<span className="sui-check__dash" />
</span>
{leadingIcon && (
<span className="sui-check__icon" aria-hidden>
{leadingIcon}
</span>
)}
{(label || description) && (
<span className="sui-check__text">
{label && <span className="sui-check__label">{label}</span>}
+12
View File
@@ -0,0 +1,12 @@
.sui-chipflow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
.sui-chipflow__sep {
color: var(--color-text-4);
font-size: 0.75rem;
line-height: 1;
flex-shrink: 0;
}
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ChipFlow } from "@shared/components/ChipFlow";
const meta: Meta<typeof ChipFlow> = {
title: "Primitives/ChipFlow",
component: ChipFlow,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
items: ["Classify", "Extract", "Name", "Normalize"],
separator: "arrow",
tone: "neutral",
size: "sm",
},
argTypes: {
separator: { control: "inline-radio", options: ["arrow", "none"] },
},
};
export default meta;
type Story = StoryObj<typeof ChipFlow>;
export const Pipeline: Story = { args: { separator: "arrow" } };
export const Plain: Story = {
args: { separator: "none", items: ["HIPAA", "GDPR", "SOC 2"] },
};
+47
View File
@@ -0,0 +1,47 @@
import { Fragment } from "react";
import type { ReactNode } from "react";
import { Chip } from "@shared/components/Chip";
import type { ChipTone, ChipSize } from "@shared/components/Chip";
import "@shared/components/ChipFlow.css";
export interface ChipFlowProps {
/** Items rendered as chips, in order. */
items: ReactNode[];
/** `arrow` joins chips with a → connector (pipeline look); `none` just wraps. */
separator?: "arrow" | "none";
tone?: ChipTone;
size?: ChipSize;
className?: string;
}
/**
* A sequence of {@link Chip}s, optionally joined by arrows to read as a
* pipeline (A B C). Use `separator="arrow"` for flows, `none` for a plain
* wrapped chip list.
*/
export function ChipFlow({
items,
separator = "none",
tone = "neutral",
size = "sm",
className,
}: ChipFlowProps) {
return (
<div
className={["sui-chipflow", className ?? ""].filter(Boolean).join(" ")}
>
{items.map((item, i) => (
<Fragment key={i}>
{i > 0 && separator === "arrow" && (
<span className="sui-chipflow__sep" aria-hidden>
</span>
)}
<Chip tone={tone} size={size}>
{item}
</Chip>
</Fragment>
))}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
.sui-datarow {
display: flex;
gap: 0.5rem;
}
.sui-datarow--center {
align-items: center;
}
.sui-datarow--top {
align-items: flex-start;
}
.sui-datarow__label {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-4);
}
.sui-datarow__value {
flex: 1;
min-width: 0;
font-size: 0.75rem;
color: var(--color-text-1);
overflow-wrap: anywhere;
}
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DataRow } from "@shared/components/DataRow";
import { ChipFlow } from "@shared/components/ChipFlow";
import { Card } from "@shared/components/Card";
const meta: Meta<typeof DataRow> = {
title: "Primitives/DataRow",
component: DataRow,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DataRow>;
export const Single: Story = {
args: { label: "Reviewer", children: "[email protected]" },
};
export const Summary: Story = {
render: () => (
<Card padding="default">
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<DataRow label="Enforces" align="top">
<ChipFlow items={["Classify", "Extract", "Name"]} />
</DataRow>
<DataRow label="Sources">3 selected</DataRow>
<DataRow label="Reviewer">matt@stirlingpdf.com</DataRow>
</div>
</Card>
),
};
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import "@shared/components/DataRow.css";
export interface DataRowProps {
/** Left-aligned key. */
label: ReactNode;
/** Value — text, chips, or any node. */
children: ReactNode;
/** Fixed key-column width (CSS length). Defaults to 4.5rem. */
labelWidth?: string;
/** Vertical alignment of label vs value. `top` suits multi-line values. */
align?: "center" | "top";
className?: string;
}
/**
* A key/value row for read-only detail/summary read-outs (a single line of a
* description list). Compose several inside a Card. Use `align="top"` when the
* value wraps (e.g. a chip flow).
*/
export function DataRow({
label,
children,
labelWidth = "4.5rem",
align = "center",
className,
}: DataRowProps) {
return (
<div
className={["sui-datarow", `sui-datarow--${align}`, className ?? ""]
.filter(Boolean)
.join(" ")}
role="group"
>
<span className="sui-datarow__label" style={{ width: labelWidth }}>
{label}
</span>
<div className="sui-datarow__value">{children}</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
.sui-iconbadge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
flex-shrink: 0;
}
.sui-iconbadge--sm {
width: 1.75rem;
height: 1.75rem;
}
.sui-iconbadge--md {
width: 2rem;
height: 2rem;
}
.sui-iconbadge--blue {
color: var(--color-blue);
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
}
.sui-iconbadge--purple {
color: var(--color-purple);
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
}
.sui-iconbadge--green {
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 14%, transparent);
}
.sui-iconbadge--amber {
color: var(--color-amber);
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
}
.sui-iconbadge--red {
color: var(--color-red);
background: color-mix(in srgb, var(--color-red) 14%, transparent);
}
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IconBadge } from "@shared/components/IconBadge";
function Glyph() {
return (
<svg viewBox="0 0 24 24" width={16} height={16} fill="currentColor">
<rect x="3" y="4" width="18" height="4" rx="1" />
<rect x="3" y="10" width="18" height="4" rx="1" />
<rect x="3" y="16" width="18" height="4" rx="1" />
</svg>
);
}
const meta: Meta<typeof IconBadge> = {
title: "Primitives/IconBadge",
component: IconBadge,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { accent: "blue", size: "md", children: <Glyph /> },
argTypes: {
accent: {
control: "inline-radio",
options: ["blue", "purple", "green", "amber", "red"],
},
size: { control: "inline-radio", options: ["sm", "md"] },
},
};
export default meta;
type Story = StoryObj<typeof IconBadge>;
export const Blue: Story = {};
export const Accents: Story = {
render: () => (
<div style={{ display: "flex", gap: 12 }}>
{(["blue", "purple", "green", "amber", "red"] as const).map((a) => (
<IconBadge key={a} accent={a}>
<Glyph />
</IconBadge>
))}
</div>
),
};
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import "@shared/components/IconBadge.css";
export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
export interface IconBadgeProps {
children: ReactNode;
/** Tone tint. Defaults to blue. */
accent?: IconBadgeAccent;
size?: "sm" | "md";
className?: string;
}
/**
* A glyph in a rounded, tone-tinted square the recurring "category icon box"
* motif used by panel headers, summaries and list leads. Centralises the tint
* so every consumer shares one treatment.
*/
export function IconBadge({
children,
accent = "blue",
size = "md",
className,
}: IconBadgeProps) {
return (
<span
className={[
"sui-iconbadge",
`sui-iconbadge--${accent}`,
`sui-iconbadge--${size}`,
className ?? "",
]
.filter(Boolean)
.join(" ")}
aria-hidden
>
{children}
</span>
);
}
+88
View File
@@ -0,0 +1,88 @@
.sui-listrow {
display: flex;
align-items: flex-start;
gap: 0.625rem;
width: 100%;
padding: 0.7rem 0.875rem;
text-align: left;
background: transparent;
color: var(--color-text-1);
}
.sui-listrow--divider {
border-top: 1px solid var(--color-border);
}
.sui-listrow--interactive {
cursor: pointer;
transition: background var(--motion-fast);
}
.sui-listrow--interactive:hover {
background: var(--color-bg-hover);
}
.sui-listrow--interactive:focus-visible {
outline: 0.125rem solid var(--color-blue);
outline-offset: -0.125rem;
}
.sui-listrow__leading {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: var(--radius-md);
flex-shrink: 0;
margin-top: 0.05rem;
color: var(--color-text-4);
background: var(--color-bg-muted);
}
.sui-listrow__leading[data-tone="success"] {
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 14%, transparent);
}
.sui-listrow__leading[data-tone="warning"] {
color: var(--color-amber);
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
}
.sui-listrow__leading[data-tone="danger"] {
color: var(--color-red);
background: color-mix(in srgb, var(--color-red) 14%, transparent);
}
.sui-listrow__leading[data-tone="info"] {
color: var(--color-blue);
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
}
.sui-listrow__leading[data-tone="purple"] {
color: var(--color-purple);
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
}
.sui-listrow__text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.sui-listrow__title {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sui-listrow__desc {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-listrow__meta {
font-size: 0.6875rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-listrow__trailing {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
}
@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ListRow } from "@shared/components/ListRow";
import { Card } from "@shared/components/Card";
import { StatusBadge } from "@shared/components/StatusBadge";
const meta: Meta<typeof ListRow> = {
title: "Primitives/ListRow",
component: ListRow,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ListRow>;
function Dot({ ch }: { ch: string }) {
return <span style={{ fontSize: 12, fontWeight: 700 }}>{ch}</span>;
}
export const Default: Story = {
args: {
leading: <Dot ch="✓" />,
leadingTone: "success",
title: "MSA_Acme_2026.pdf",
description: "Classified as Contract • 3 tables extracted",
meta: "2h ago",
},
};
/** A divided list inside a padding-none Card — the canonical usage. */
export const InCard: Story = {
render: () => (
<Card padding="none">
<ListRow
leading={<Dot ch="✓" />}
leadingTone="success"
title="MSA_Acme_2026.pdf"
description="Classified as Contract • 3 tables extracted"
meta="2h ago"
/>
<ListRow
divider
leading={<Dot ch="!" />}
leadingTone="warning"
title="scan_002.pdf"
description="Low confidence (62%) • flagged for review"
meta="Yesterday"
trailing={
<StatusBadge tone="warning" size="sm">
Flagged
</StatusBadge>
}
/>
<ListRow
divider
leading={<Dot ch="✓" />}
leadingTone="success"
title="Invoice_4471.pdf"
description="Classified as Invoice • renamed to standard"
meta="5h ago"
/>
</Card>
),
};
export const Interactive: Story = {
args: {
leading: <Dot ch="→" />,
leadingTone: "info",
title: "Clickable row",
description: "The whole row is a button",
onClick: () => {},
},
};
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
import "@shared/components/ListRow.css";
import type { StatusTone } from "@shared/components/StatusBadge";
export interface ListRowProps {
/** Leading visual (icon/avatar), shown in a tone-tinted square. */
leading?: ReactNode;
/** Tint for the leading square. Defaults to neutral. */
leadingTone?: StatusTone;
/** Primary line. */
title: ReactNode;
/** Secondary line. */
description?: ReactNode;
/** Tertiary line (e.g. timestamp). */
meta?: ReactNode;
/** Right-aligned content (badge, chevron, action). */
trailing?: ReactNode;
/** Makes the whole row a button. */
onClick?: () => void;
/** Draw a top hairline — set on every row after the first inside a list. */
divider?: boolean;
className?: string;
}
/**
* A single list row: a tone-tinted leading glyph + a title/description/meta
* stack + an optional trailing slot. Compose a divided list by wrapping rows in
* a {@code Card padding="none"} and setting {@code divider} on all but the first.
*/
export function ListRow({
leading,
leadingTone = "neutral",
title,
description,
meta,
trailing,
onClick,
divider,
className,
}: ListRowProps) {
const classes = [
"sui-listrow",
divider ? "sui-listrow--divider" : "",
onClick ? "sui-listrow--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const content = (
<>
{leading && (
<span
className="sui-listrow__leading"
data-tone={leadingTone}
aria-hidden
>
{leading}
</span>
)}
<span className="sui-listrow__text">
<span className="sui-listrow__title">{title}</span>
{description && (
<span className="sui-listrow__desc">{description}</span>
)}
{meta && <span className="sui-listrow__meta">{meta}</span>}
</span>
{trailing && <span className="sui-listrow__trailing">{trailing}</span>}
</>
);
return onClick ? (
<button type="button" className={classes} onClick={onClick}>
{content}
</button>
) : (
<div className={classes}>{content}</div>
);
}
+12
View File
@@ -17,6 +17,18 @@
color: inherit;
text-align: left;
}
/* Compact density for narrow rails / metric strips. */
.sui-metric--sm {
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
min-width: 0;
}
.sui-metric--sm .sui-metric__value {
font-size: 1.125rem;
}
.sui-metric--sm .sui-metric__label {
white-space: nowrap;
}
.sui-metric--primary {
background: var(--color-bg-subtle);
}
@@ -14,6 +14,8 @@ export interface MetricCardProps {
deltaDirection?: DeltaDirection;
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
emphasis?: "default" | "primary";
/** Density. `sm` tightens padding + value size for narrow rails/strips. */
size?: "sm" | "md";
/** Optional icon shown in the top-right corner. */
icon?: ReactNode;
onClick?: () => void;
@@ -41,6 +43,7 @@ export function MetricCard({
delta,
deltaDirection,
emphasis = "default",
size = "md",
icon,
onClick,
className,
@@ -50,6 +53,7 @@ export function MetricCard({
const classes = [
"sui-metric",
`sui-metric--${size}`,
emphasis === "primary" ? "sui-metric--primary" : "",
interactive ? "sui-metric--interactive" : "",
className ?? "",
+45
View File
@@ -40,8 +40,53 @@
.sui-navitem__trailing {
display: inline-flex;
align-items: center;
margin-left: auto;
}
.sui-navitem:focus-visible {
outline: 0.125rem solid var(--color-blue);
outline-offset: 0.125rem;
}
/* ---- Status accent (optional) ---- */
.sui-navitem--accent {
position: relative;
}
.sui-navitem--accent::before {
content: "";
position: absolute;
left: 0;
top: 0.3125rem;
bottom: 0.3125rem;
width: 3px;
border-radius: 3px;
}
.sui-navitem[data-accent="blue"]::before {
background: var(--color-blue);
}
.sui-navitem[data-accent="purple"]::before {
background: var(--color-purple);
}
.sui-navitem[data-accent="green"]::before {
background: var(--color-green);
}
.sui-navitem[data-accent="amber"]::before {
background: var(--color-amber);
}
.sui-navitem[data-accent="red"]::before {
background: var(--color-red);
}
.sui-navitem[data-accent="blue"] .sui-navitem__icon {
color: var(--color-blue);
}
.sui-navitem[data-accent="purple"] .sui-navitem__icon {
color: var(--color-purple);
}
.sui-navitem[data-accent="green"] .sui-navitem__icon {
color: var(--color-green);
}
.sui-navitem[data-accent="amber"] .sui-navitem__icon {
color: var(--color-amber);
}
.sui-navitem[data-accent="red"] .sui-navitem__icon {
color: var(--color-red);
}
+16 -2
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import "@shared/components/NavItem.css";
export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
export interface NavItemProps {
/** Stable view id passed to the click handler. */
id: string;
@@ -8,6 +10,11 @@ export interface NavItemProps {
icon?: ReactNode;
/** Show the active highlight (navActive background, navActiveText colour). */
isActive?: boolean;
/**
* Optional status accent: draws a left edge bar in the tone colour and tints
* the leading icon to match. For listing live/paused/etc. entities.
*/
accent?: NavItemAccent;
/** Optional trailing badge (e.g. unread count, "new"). */
trailing?: ReactNode;
onClick?: (id: string) => void;
@@ -19,13 +26,14 @@ export interface NavItemProps {
*
* Active styling: navActive background, navActiveText colour, weight 500.
* Hover styling: navHover background, navHoverText colour (only when not
* already active).
* already active). An optional `accent` adds a status edge bar + icon tint.
*/
export function NavItem({
id,
label,
icon,
isActive,
accent,
trailing,
onClick,
className,
@@ -34,9 +42,15 @@ export function NavItem({
<button
type="button"
onClick={() => onClick?.(id)}
className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
className={[
"sui-navitem",
isActive ? "is-active" : "",
accent ? "sui-navitem--accent" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
data-accent={accent}
aria-current={isActive ? "page" : undefined}
>
{icon && (
+11 -5
View File
@@ -16,16 +16,22 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
color: var(--color-text-3);
flex-shrink: 0;
width: 1.875rem;
height: 1.875rem;
border: 1px solid var(--color-border);
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-2);
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
color var(--motion-fast),
border-color var(--motion-fast);
}
.sui-panelhdr__back:hover {
background: var(--color-bg-hover);
border-color: var(--color-text-4);
color: var(--color-text-1);
}
.sui-panelhdr__text {
+14 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import "@shared/components/PanelHeader.css";
import { IconBadge } from "@shared/components/IconBadge";
export interface PanelHeaderProps {
title: ReactNode;
@@ -7,6 +8,10 @@ export interface PanelHeaderProps {
subtitle?: ReactNode;
/** Show a back chevron and trigger this callback when clicked. */
onBack?: () => void;
/** Optional leading visual (e.g. a category glyph) shown in a tinted box. */
icon?: ReactNode;
/** Accent tint for the leading icon box. Defaults to blue. */
iconAccent?: "blue" | "purple" | "green" | "amber" | "red";
/** Right-aligned action buttons / chips. */
actions?: ReactNode;
className?: string;
@@ -14,12 +19,15 @@ export interface PanelHeaderProps {
/**
* Header strip used by drill-down panels (admin tabs, agent detail, settings
* sub-pages). Back chevron renders only when `onBack` is supplied.
* sub-pages). Back chevron renders only when `onBack` is supplied; an optional
* leading `icon` renders in a tinted box before the title.
*/
export function PanelHeader({
title,
subtitle,
onBack,
icon,
iconAccent = "blue",
actions,
className,
}: PanelHeaderProps) {
@@ -49,6 +57,11 @@ export function PanelHeader({
</svg>
</button>
)}
{icon && (
<IconBadge accent={iconAccent} size="md">
{icon}
</IconBadge>
)}
<div className="sui-panelhdr__text">
<div className="sui-panelhdr__title">{title}</div>
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
@@ -0,0 +1,33 @@
.sui-sectionhdr {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0;
background: transparent;
border: none;
text-align: left;
}
button.sui-sectionhdr {
cursor: pointer;
}
.sui-sectionhdr__title {
flex: 1;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-4);
}
.sui-sectionhdr__count {
font-size: 0.6875rem;
color: var(--color-text-4);
}
.sui-sectionhdr__chevron {
color: var(--color-text-4);
flex-shrink: 0;
transition: transform var(--motion-fast);
}
.sui-sectionhdr__chevron[data-collapsed="true"] {
transform: rotate(-90deg);
}
@@ -0,0 +1,36 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SectionHeader } from "@shared/components/SectionHeader";
const meta: Meta<typeof SectionHeader> = {
title: "Primitives/SectionHeader",
component: SectionHeader,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "20rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SectionHeader>;
export const Static: Story = { args: { title: "Policies", count: "3 active" } };
export const Collapsible: Story = {
render: () => {
const [open, setOpen] = useState(true);
return (
<SectionHeader
title="Policies"
count="3 active"
collapsible
expanded={open}
onToggle={() => setOpen((v) => !v)}
/>
);
},
};
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
import "@shared/components/SectionHeader.css";
export interface SectionHeaderProps {
/** Uppercase eyebrow title. */
title: ReactNode;
/** Optional right-aligned tally/count. */
count?: ReactNode;
/** Render as a button with a disclosure chevron. */
collapsible?: boolean;
/** When collapsible, whether the section is expanded. */
expanded?: boolean;
onToggle?: () => void;
className?: string;
}
/**
* An uppercase section eyebrow with an optional trailing count and, when
* `collapsible`, a disclosure chevron + toggle. Use above a group of rows/cards.
*/
export function SectionHeader({
title,
count,
collapsible,
expanded = true,
onToggle,
className,
}: SectionHeaderProps) {
const classes = ["sui-sectionhdr", className ?? ""].filter(Boolean).join(" ");
const inner = (
<>
<span className="sui-sectionhdr__title">{title}</span>
{count != null && <span className="sui-sectionhdr__count">{count}</span>}
{collapsible && (
<svg
className="sui-sectionhdr__chevron"
data-collapsed={!expanded}
viewBox="0 0 24 24"
width={14}
height={14}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="6 9 12 15 18 9" />
</svg>
)}
</>
);
return collapsible ? (
<button
type="button"
className={classes}
onClick={onToggle}
aria-expanded={expanded}
>
{inner}
</button>
) : (
<div className={classes}>{inner}</div>
);
}
+19
View File
@@ -44,6 +44,25 @@
-webkit-appearance: none;
}
/*
* The native option popup is OS-rendered: without explicit colours it falls
* back to the UA scheme (white) while the option text inherits the themed
* (light) colour i.e. white-on-white in dark mode wherever the surrounding
* app keeps color-scheme: light (e.g. the editor, whose dark mode is driven by
* Mantine, not color-scheme). Theme the options explicitly and pin the
* select's color-scheme to the active SUI theme so the popup is always legible.
*/
.sui-select__el option {
background-color: var(--color-surface);
color: var(--color-text-1);
}
[data-theme="dark"] .sui-select__el {
color-scheme: dark;
}
[data-theme="light"] .sui-select__el {
color-scheme: light;
}
.sui-select--sm .sui-select__el {
font-size: 0.8125rem;
padding-left: var(--space-2);
@@ -0,0 +1,30 @@
.sui-settingsrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
}
.sui-settingsrow__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.sui-settingsrow__label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
.sui-settingsrow__desc {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-settingsrow__control {
flex: 0 0 auto;
min-width: 0;
max-width: 11rem;
display: inline-flex;
align-items: center;
justify-content: flex-end;
}
@@ -0,0 +1,82 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsRow } from "@shared/components/SettingsRow";
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
import { Select } from "@shared/components/Select";
import { Card } from "@shared/components/Card";
const meta: Meta<typeof SettingsRow> = {
title: "Primitives/SettingsRow",
component: SettingsRow,
tags: ["autodocs"],
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SettingsRow>;
export const Toggle: Story = {
args: {
label: "Auto-classify",
control: <ToggleSwitch checked onChange={() => {}} size="sm" />,
},
};
export const WithDescription: Story = {
args: {
label: "Detect PII",
description: "Scan documents for sensitive fields on save",
control: <ToggleSwitch checked onChange={() => {}} size="sm" />,
},
};
/** A settings list inside a padding-none Card. */
export const List: Story = {
render: () => (
<Card padding="none">
{[
{ label: "Auto-classify", on: true },
{ label: "Extract tables", on: true },
{ label: "Strip blank pages", on: false },
].map((r, i) => (
<div
key={r.label}
style={{
padding: "0.7rem 0.875rem",
borderTop: i > 0 ? "1px solid var(--color-border)" : undefined,
}}
>
<SettingsRow
label={r.label}
control={
<ToggleSwitch checked={r.on} onChange={() => {}} size="sm" />
}
/>
</div>
))}
</Card>
),
};
export const SelectControl: Story = {
args: {
label: "OCR level",
control: (
<Select
inputSize="sm"
value="high"
onChange={() => {}}
options={[
{ value: "standard", label: "Standard" },
{ value: "high", label: "High" },
{ value: "max", label: "Maximum" },
]}
/>
),
},
};
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import "@shared/components/SettingsRow.css";
export interface SettingsRowProps {
/** Left-aligned setting name. */
label: ReactNode;
/** Optional secondary line under the label. */
description?: ReactNode;
/** Right-aligned control (toggle / select / input / button). */
control: ReactNode;
className?: string;
}
/**
* Horizontal settings row: label (+ optional description) on the left, control
* right-aligned. The companion to vertical {@link FormField} use this for the
* label-left/control-right settings pattern (the layout ToggleSwitch's docs
* point at). Stack rows inside a {@code Card padding="none"} for a settings list.
*/
export function SettingsRow({
label,
description,
control,
className,
}: SettingsRowProps) {
return (
<div
className={["sui-settingsrow", className ?? ""].filter(Boolean).join(" ")}
>
<div className="sui-settingsrow__text">
<span className="sui-settingsrow__label">{label}</span>
{description && (
<span className="sui-settingsrow__desc">{description}</span>
)}
</div>
<div className="sui-settingsrow__control">{control}</div>
</div>
);
}
@@ -0,0 +1,27 @@
.sui-steps {
display: flex;
gap: 0.3rem;
width: 100%;
}
.sui-steps__bar {
flex: 1;
border-radius: 999px;
background: var(--color-border);
transition: background var(--motion-fast);
}
.sui-steps--md .sui-steps__bar {
height: 0.3rem;
}
.sui-steps--sm .sui-steps__bar {
height: 0.2rem;
}
/* Completed steps: solid accent. */
.sui-steps__bar[data-state="done"] {
background: var(--color-blue);
}
/* Current step: solid accent + a soft ring so it reads as "you are here". */
.sui-steps__bar[data-state="current"] {
background: var(--color-blue);
box-shadow: 0 0 0 0.1875rem
color-mix(in srgb, var(--color-blue) 22%, transparent);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StepIndicator } from "@shared/components/StepIndicator";
const meta: Meta<typeof StepIndicator> = {
title: "Primitives/StepIndicator",
component: StepIndicator,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { total: 3, current: 2, size: "md" },
argTypes: {
current: { control: { type: "number", min: 1, max: 5 } },
total: { control: { type: "number", min: 1, max: 5 } },
size: { control: "inline-radio", options: ["sm", "md"] },
},
decorators: [
(S) => (
<div style={{ maxWidth: "20rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof StepIndicator>;
export const Step1: Story = { args: { total: 3, current: 1 } };
export const Step2: Story = { args: { total: 3, current: 2 } };
export const Step3: Story = { args: { total: 3, current: 3 } };
export const Small: Story = { args: { total: 4, current: 2, size: "sm" } };
@@ -0,0 +1,43 @@
import "@shared/components/StepIndicator.css";
export interface StepIndicatorProps {
/** Total number of steps. */
total: number;
/** Current step, 1-based. */
current: number;
size?: "sm" | "md";
className?: string;
}
/**
* A segmented step/progress rail for multi-step flows (wizards, onboarding).
* Segments before `current` read as completed, the `current` segment is
* emphasised, and the rest are upcoming.
*/
export function StepIndicator({
total,
current,
size = "md",
className,
}: StepIndicatorProps) {
return (
<div
className={["sui-steps", `sui-steps--${size}`, className ?? ""]
.filter(Boolean)
.join(" ")}
role="progressbar"
aria-valuemin={1}
aria-valuemax={total}
aria-valuenow={current}
>
{Array.from({ length: total }, (_, i) => {
const step = i + 1;
const state =
step < current ? "done" : step === current ? "current" : "upcoming";
return (
<span key={step} className="sui-steps__bar" data-state={state} />
);
})}
</div>
);
}