mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
feat(policies): backend-driven policy enforcement (frontend) (#6598)
## Summary Adds the **Policies** feature (proprietary, behind the `POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed tool pipeline on documents, docked in the right tool sidebar alongside Tools. ## Highlights - **Policy catalog** — 5 categories; **Security** is wired (redact PII + sanitize), the others are marked "Coming soon". - **Backend as source of truth** — policies persist via the Policies engine (`/api/v1/policies`), one policy per category, with a local cache + offline fallback. - **Auto-run** — enabled policies run on every uploaded file: dispatch → poll → import outputs into the workspace. - **Security redact config** — PII preset dropdown + custom word/regex entry + advanced options; tool params map to the backend endpoint fields. - **Activity feed** with retry on failures; **file badges** showing which policies ran on a file (sidebar + files page), tinted to the policy colour. - Reuses the **Watched Folders** engine for each policy's backing folder; policy-owned folders are filtered out of the Watched Folders UI. ## Notes - Gated by `POLICIES_ENABLED` (true in proprietary, false in core) — unreachable in the open-source build. - Frontend-only diff; depends on the backend Policies engine and the merged Watched Folders feature.
This commit is contained in:
@@ -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 & 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 51–55 + 2221–2720,
|
||||
// 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 types↔services 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";
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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"] },
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user