mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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 { resolve } from "node:path";
|
||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
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
|
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
|
||||||
* addon list is just the extras we want: theme switching + a11y auditing.
|
* addon list is just the extras we want: theme switching + a11y auditing.
|
||||||
*
|
*
|
||||||
* Story files live next to their components in shared/ and portal/src/.
|
* Story files live next to their components in shared/, portal/src/, and
|
||||||
* MDX docs pages live in portal/src/docs/.
|
* 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 = {
|
const config: StorybookConfig = {
|
||||||
stories: [
|
stories: [
|
||||||
@@ -14,6 +16,7 @@ const config: StorybookConfig = {
|
|||||||
"../portal/src/**/*.stories.@(ts|tsx)",
|
"../portal/src/**/*.stories.@(ts|tsx)",
|
||||||
"../shared/**/*.mdx",
|
"../shared/**/*.mdx",
|
||||||
"../shared/**/*.stories.@(ts|tsx)",
|
"../shared/**/*.stories.@(ts|tsx)",
|
||||||
|
"../editor/src/**/*.stories.@(ts|tsx)",
|
||||||
],
|
],
|
||||||
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
|
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
|
||||||
framework: {
|
framework: {
|
||||||
@@ -28,13 +31,26 @@ const config: StorybookConfig = {
|
|||||||
staticDirs: ["../portal/public"],
|
staticDirs: ["../portal/public"],
|
||||||
viteFinal: async (config) => {
|
viteFinal: async (config) => {
|
||||||
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
|
// 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 = config.resolve ?? {};
|
||||||
config.resolve.alias = {
|
config.resolve.alias = {
|
||||||
...(config.resolve.alias ?? {}),
|
...(config.resolve.alias ?? {}),
|
||||||
"@portal": resolve(__dirname, "../portal/src"),
|
"@portal": resolve(__dirname, "../portal/src"),
|
||||||
"@shared": resolve(__dirname, "../shared"),
|
"@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;
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8193,6 +8193,9 @@ confirm = "Extract"
|
|||||||
message = "This ZIP contains {{count}} files. Extract anyway?"
|
message = "This ZIP contains {{count}} files. Extract anyway?"
|
||||||
title = "Large ZIP File"
|
title = "Large ZIP File"
|
||||||
|
|
||||||
|
[policies]
|
||||||
|
sidebarTitle = "Policies"
|
||||||
|
|
||||||
[watchedFolders]
|
[watchedFolders]
|
||||||
alreadyInFolder = "Already in this folder"
|
alreadyInFolder = "Already in this folder"
|
||||||
deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected."
|
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 { useTranslation } from "react-i18next";
|
||||||
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
|
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
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 { FileId } from "@app/types/file";
|
||||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||||
import { useFolders } from "@app/contexts/FolderContext";
|
import { useFolders } from "@app/contexts/FolderContext";
|
||||||
|
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||||
import {
|
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 {
|
interface FileCardProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
@@ -731,6 +758,7 @@ function FileCard({
|
|||||||
<span>{fileSize}</span>
|
<span>{fileSize}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{fileDate}</span>
|
<span>{fileDate}</span>
|
||||||
|
<PolicyBadges fileId={file.id as string} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="files-page-card-actions">
|
<div className="files-page-card-actions">
|
||||||
@@ -1315,6 +1343,7 @@ function FileRow({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<FileOriginBadge origin={getFileOrigin(file)} compact />
|
<FileOriginBadge origin={getFileOrigin(file)} compact />
|
||||||
|
<PolicyBadges fileId={file.id as string} />
|
||||||
{isInWorkspace && (
|
{isInWorkspace && (
|
||||||
<span className="files-page-row-open-pill">
|
<span className="files-page-row-open-pill">
|
||||||
<span className="files-page-card-open-dot" />
|
<span className="files-page-card-open-dot" />
|
||||||
|
|||||||
@@ -484,6 +484,24 @@
|
|||||||
gap: 0.4rem;
|
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
|
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
|
||||||
the user can tell which folder each hit lives in without navigating. */
|
the user can tell which folder each hit lives in without navigating. */
|
||||||
.files-page-card-path {
|
.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 { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||||
|
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||||
import {
|
import {
|
||||||
setWatchedFolderDraggedFileIds,
|
setWatchedFolderDraggedFileIds,
|
||||||
clearWatchedFolderDraggedFileIds,
|
clearWatchedFolderDraggedFileIds,
|
||||||
@@ -131,6 +132,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
// the workbench). The same map drives the per-file membership dots.
|
// the workbench). The same map drives the per-file membership dots.
|
||||||
const folderMembership = useFolderMembership();
|
const folderMembership = useFolderMembership();
|
||||||
const allFolders = useAllWatchedFolders();
|
const allFolders = useAllWatchedFolders();
|
||||||
|
const policyFileBadges = usePolicyFileBadges();
|
||||||
const folderById = useMemo(
|
const folderById = useMemo(
|
||||||
() => new Map(allFolders.map((f) => [f.id, f])),
|
() => new Map(allFolders.map((f) => [f.id, f])),
|
||||||
[allFolders],
|
[allFolders],
|
||||||
@@ -892,6 +894,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
onDragStart={handleWatchedFolderDragStart}
|
onDragStart={handleWatchedFolderDragStart}
|
||||||
folders={memberFolders}
|
folders={memberFolders}
|
||||||
onFolderClick={openWatchedFolder}
|
onFolderClick={openWatchedFolder}
|
||||||
|
policies={
|
||||||
|
policyFileBadges.get(stub.id as string) ?? []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -134,6 +134,25 @@
|
|||||||
text-overflow: ellipsis;
|
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 ---- */
|
/* ---- Folder membership tags ---- */
|
||||||
.file-sidebar-folder-tags {
|
.file-sidebar-folder-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||||
|
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||||
import type { FileId } from "@app/types/file";
|
import type { FileId } from "@app/types/file";
|
||||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||||
@@ -125,6 +126,14 @@ export interface FileItemFolderRef {
|
|||||||
accentColor: string;
|
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 {
|
export interface FileItemProps {
|
||||||
fileId: FileId;
|
fileId: FileId;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -143,9 +152,12 @@ export interface FileItemProps {
|
|||||||
folders?: FileItemFolderRef[];
|
folders?: FileItemFolderRef[];
|
||||||
/** Clicking a membership dot opens that folder. */
|
/** Clicking a membership dot opens that folder. */
|
||||||
onFolderClick?: (folderId: string) => void;
|
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_FOLDER_TAGS = 2;
|
||||||
|
const MAX_VISIBLE_POLICY_BADGES = 3;
|
||||||
|
|
||||||
export function FileItem({
|
export function FileItem({
|
||||||
fileId,
|
fileId,
|
||||||
@@ -162,6 +174,7 @@ export function FileItem({
|
|||||||
onDragStart,
|
onDragStart,
|
||||||
folders = [],
|
folders = [],
|
||||||
onFolderClick,
|
onFolderClick,
|
||||||
|
policies = [],
|
||||||
}: FileItemProps) {
|
}: FileItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ext = getFileExtension(name);
|
const ext = getFileExtension(name);
|
||||||
@@ -237,6 +250,25 @@ export function FileItem({
|
|||||||
{dateLabel && typeLabel ? " · " : ""}
|
{dateLabel && typeLabel ? " · " : ""}
|
||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</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>
|
</span>
|
||||||
{folders.length > 0 && (
|
{folders.length > 0 && (
|
||||||
<span className="file-sidebar-folder-tags" data-no-select>
|
<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 { MantineProvider } from "@mantine/core";
|
||||||
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
|
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
|
||||||
import { mantineTheme } from "@app/theme/mantineTheme";
|
import { mantineTheme } from "@app/theme/mantineTheme";
|
||||||
@@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast";
|
|||||||
import ToastRenderer from "@app/components/toast/ToastRenderer";
|
import ToastRenderer from "@app/components/toast/ToastRenderer";
|
||||||
import { ToastPortalBinder } from "@app/components/toast";
|
import { ToastPortalBinder } from "@app/components/toast";
|
||||||
import type { ThemeMode } from "@app/constants/theme";
|
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 {
|
interface RainbowThemeContextType {
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
|||||||
const mantineColorScheme =
|
const mantineColorScheme =
|
||||||
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
|
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 (
|
return (
|
||||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import {
|
|||||||
AgentsSection,
|
AgentsSection,
|
||||||
useAgentsEnabled,
|
useAgentsEnabled,
|
||||||
} from "@app/components/agents/AgentsPanel";
|
} 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 { useChat } from "@app/components/chat/ChatContext";
|
||||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||||
@@ -70,6 +78,8 @@ export default function RightSidebar() {
|
|||||||
} = useToolWorkflow();
|
} = useToolWorkflow();
|
||||||
|
|
||||||
const agentsEnabled = useAgentsEnabled();
|
const agentsEnabled = useAgentsEnabled();
|
||||||
|
const policiesEnabled = usePoliciesEnabled();
|
||||||
|
const rawPolicyDetailActive = usePolicyDetailActive();
|
||||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||||
const fullscreenGeometry = useToolPanelGeometry({
|
const fullscreenGeometry = useToolPanelGeometry({
|
||||||
enabled: fullscreenExpanded,
|
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 —
|
// 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.
|
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||||
const inToolView = leftPanelView !== "toolPicker";
|
const inToolView = leftPanelView !== "toolPicker";
|
||||||
// Show X (close) button only when there's somewhere to go back to.
|
// Show X (close) button only when there's somewhere to go back to.
|
||||||
const showCloseButton = inToolView || allToolsView;
|
const showCloseButton = inToolView || allToolsView;
|
||||||
// Show search input whenever there's a close button, or when agents are off and
|
// Policies sit above the tool list in the default tool-picker view.
|
||||||
// we're in the default tool-picker view (search filters the full list inline).
|
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 =
|
const showHeaderSearch =
|
||||||
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
|
!showInlineSearch &&
|
||||||
|
(showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"));
|
||||||
|
|
||||||
const handleHeaderBack = () => {
|
const handleHeaderBack = () => {
|
||||||
if (inToolView) {
|
if (inToolView) {
|
||||||
@@ -201,11 +231,21 @@ export default function RightSidebar() {
|
|||||||
const showAgents =
|
const showAgents =
|
||||||
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
|
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 = () => {
|
const computedWidth = () => {
|
||||||
if (isMobile) return "100%";
|
if (isMobile) return "100%";
|
||||||
if (isChatOpen) return `${chatWidthPx}px`;
|
if (isChatOpen) return `${chatWidthPx}px`;
|
||||||
if (!isPanelVisible) return "3.5rem";
|
if (!isPanelVisible) return "3.5rem";
|
||||||
return "18.5rem";
|
return expandedWidth;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collapsed rail: show favourites + recommended tools as icons.
|
// Collapsed rail: show favourites + recommended tools as icons.
|
||||||
@@ -248,6 +288,8 @@ export default function RightSidebar() {
|
|||||||
...(isChatDragging ? { transition: "none" } : {}),
|
...(isChatDragging ? { transition: "none" } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Headless: enforces enabled policies on every uploaded file. */}
|
||||||
|
{policiesEnabled && <PolicyAutoRunController />}
|
||||||
{!fullscreenExpanded && isChatOpen && (
|
{!fullscreenExpanded && isChatOpen && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -279,7 +321,7 @@ export default function RightSidebar() {
|
|||||||
color="gray.4"
|
color="gray.4"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
size="md"
|
||||||
className="tool-panel__expand-btn"
|
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||||
onClick={handleExpand}
|
onClick={handleExpand}
|
||||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||||
>
|
>
|
||||||
@@ -288,6 +330,7 @@ export default function RightSidebar() {
|
|||||||
<AgentsCollapsedButton onExpand={handleExpand} />
|
<AgentsCollapsedButton onExpand={handleExpand} />
|
||||||
</div>
|
</div>
|
||||||
<div className="tool-panel__collapsed-divider" />
|
<div className="tool-panel__collapsed-divider" />
|
||||||
|
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
|
||||||
<div className="tool-panel__collapsed-tools">
|
<div className="tool-panel__collapsed-tools">
|
||||||
{collapsedRailItems.map(({ id, tool }) => (
|
{collapsedRailItems.map(({ id, tool }) => (
|
||||||
<AppTooltip
|
<AppTooltip
|
||||||
@@ -326,84 +369,122 @@ export default function RightSidebar() {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: isMobile ? "100%" : "18.5rem",
|
width: isMobile ? "100%" : expandedWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="tool-panel__compact-header">
|
{policyDetailActive ? (
|
||||||
{activeTool ? (
|
<div className="pol-takeover">
|
||||||
<div
|
<PolicyDetailTakeover />
|
||||||
className="tool-panel__active-tool-pill"
|
</div>
|
||||||
aria-label={activeTool.name}
|
) : (
|
||||||
>
|
<>
|
||||||
<span className="tool-panel__active-tool-pill-icon">
|
{!showPolicies && (
|
||||||
<ToolIcon
|
<div className="tool-panel__compact-header">
|
||||||
icon={activeTool.icon}
|
{activeTool ? (
|
||||||
marginRight="0"
|
<div
|
||||||
color="var(--mantine-color-blue-filled)"
|
className="tool-panel__active-tool-pill"
|
||||||
/>
|
aria-label={activeTool.name}
|
||||||
</span>
|
>
|
||||||
<span className="tool-panel__active-tool-pill-label">
|
<span className="tool-panel__active-tool-pill-icon">
|
||||||
{activeTool.name}
|
<ToolIcon
|
||||||
</span>
|
icon={activeTool.icon}
|
||||||
</div>
|
marginRight="0"
|
||||||
) : showHeaderSearch ? (
|
color="var(--mantine-color-blue-filled)"
|
||||||
<div className="tool-panel__compact-header-search">
|
/>
|
||||||
<ToolSearch
|
</span>
|
||||||
value={searchQuery}
|
<span className="tool-panel__active-tool-pill-label">
|
||||||
onChange={handleHeaderSearchChange}
|
{activeTool.name}
|
||||||
toolRegistry={toolRegistry}
|
</span>
|
||||||
mode="filter"
|
</div>
|
||||||
autoFocus={allToolsView && !inToolView}
|
) : 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
|
<ToolPanel
|
||||||
allToolsView={allToolsView}
|
allToolsView={allToolsView}
|
||||||
onShowAllTools={handleShowAllTools}
|
onShowAllTools={handleShowAllTools}
|
||||||
onToolSelect={handleToolSelectWithTransition}
|
onToolSelect={handleToolSelectWithTransition}
|
||||||
compact={agentsEnabled && !allToolsView}
|
compact={agentsEnabled && !allToolsView}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,13 @@
|
|||||||
border-color: var(--border-subtle) !important;
|
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 {
|
.tool-panel__expand-btn svg {
|
||||||
color: var(--text-secondary) !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
@@ -271,6 +278,22 @@
|
|||||||
width: 100%;
|
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 {
|
.tool-panel__active-tool-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,11 +4,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
|
* Watched Folders. Disabled for now — gates both the custom workbench
|
||||||
* custom workbench registration (seeding, URL sync, view) and the sidebar
|
* registration (seeding, URL sync, view) and the sidebar entry point, so the
|
||||||
* entry point, so the feature is unreachable from the UI. Flip to `true` to
|
* feature is unreachable from the UI. Flip to `true` to restore access.
|
||||||
* restore access.
|
|
||||||
*/
|
*/
|
||||||
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
|
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
|
||||||
// as constant/unreachable conditions by the type checker and linter.
|
// as constant/unreachable conditions by the type checker and linter.
|
||||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to load smart folders:", 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) =>
|
await page.route("**/api/v1/info/wau", (route: Route) =>
|
||||||
route.fulfill({ json: { count: 0 } }),
|
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
|
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
|
||||||
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
|
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
|
||||||
inputSource?: "idb" | "local-folder";
|
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 {
|
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
|
* Watched Folders — a proprietary feature whose implementation lives under
|
||||||
* implementation lives under `proprietary/`. Still disabled for now; flip to
|
* `proprietary/`. Still disabled for now; flip to `true` to surface it in the
|
||||||
* `true` to surface it in the proprietary build only. The core override stays
|
* proprietary build only. The core override stays `false`, so the shared
|
||||||
* `false`, so the shared sidebar entry point never appears in the open-source
|
* sidebar entry point never appears in the open-source build (which has no
|
||||||
* build (which has no Watched Folders implementation to navigate to).
|
* Watched Folders implementation to navigate to).
|
||||||
*/
|
*/
|
||||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
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 () => {
|
const refreshFolders = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Policy-owned folders are managed by Policies, not shown here.
|
||||||
const all = await watchedFolderStorage.getAllFolders();
|
const all = await watchedFolderStorage.getAllFolders();
|
||||||
setFolders(all);
|
setFolders(all.filter((f) => !f.policyCategoryId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load smart folders:", error);
|
console.error("Failed to load smart folders:", error);
|
||||||
} finally {
|
} 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(),
|
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: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
// make sure this port matches the devUrl port in tauri.conf.json file
|
// make sure this port matches the devUrl port in tauri.conf.json file
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -42,6 +48,9 @@ export default defineConfig({
|
|||||||
projects: ["./tsconfig.core.vite.json"],
|
projects: ["./tsconfig.core.vite.json"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@shared": sharedDir },
|
||||||
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
target: "es2020",
|
target: "es2020",
|
||||||
},
|
},
|
||||||
@@ -60,6 +69,9 @@ export default defineConfig({
|
|||||||
projects: ["./tsconfig.proprietary.vite.json"],
|
projects: ["./tsconfig.proprietary.vite.json"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@shared": sharedDir },
|
||||||
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
target: "es2020",
|
target: "es2020",
|
||||||
},
|
},
|
||||||
@@ -78,6 +90,9 @@ export default defineConfig({
|
|||||||
projects: ["./tsconfig.desktop.vite.json"],
|
projects: ["./tsconfig.desktop.vite.json"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@shared": sharedDir },
|
||||||
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
target: "es2020",
|
target: "es2020",
|
||||||
},
|
},
|
||||||
@@ -96,6 +111,9 @@ export default defineConfig({
|
|||||||
projects: ["./tsconfig.saas.vite.json"],
|
projects: ["./tsconfig.saas.vite.json"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@shared": sharedDir },
|
||||||
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
target: "es2020",
|
target: "es2020",
|
||||||
},
|
},
|
||||||
@@ -114,6 +132,9 @@ export default defineConfig({
|
|||||||
projects: ["./tsconfig.prototypes.vite.json"],
|
projects: ["./tsconfig.prototypes.vite.json"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@shared": sharedDir },
|
||||||
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
target: "es2020",
|
target: "es2020",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,6 +75,12 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sui-check__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.sui-check__text {
|
.sui-check__text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit<
|
|||||||
> {
|
> {
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
description?: 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). */
|
/** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
function Checkbox(
|
function Checkbox(
|
||||||
{ label, description, indeterminate, className, id, ...rest },
|
{ label, description, leadingIcon, indeterminate, className, id, ...rest },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +59,11 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="sui-check__dash" />
|
<span className="sui-check__dash" />
|
||||||
</span>
|
</span>
|
||||||
|
{leadingIcon && (
|
||||||
|
<span className="sui-check__icon" aria-hidden>
|
||||||
|
{leadingIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{(label || description) && (
|
{(label || description) && (
|
||||||
<span className="sui-check__text">
|
<span className="sui-check__text">
|
||||||
{label && <span className="sui-check__label">{label}</span>}
|
{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;
|
color: inherit;
|
||||||
text-align: left;
|
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 {
|
.sui-metric--primary {
|
||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface MetricCardProps {
|
|||||||
deltaDirection?: DeltaDirection;
|
deltaDirection?: DeltaDirection;
|
||||||
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
|
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
|
||||||
emphasis?: "default" | "primary";
|
emphasis?: "default" | "primary";
|
||||||
|
/** Density. `sm` tightens padding + value size for narrow rails/strips. */
|
||||||
|
size?: "sm" | "md";
|
||||||
/** Optional icon shown in the top-right corner. */
|
/** Optional icon shown in the top-right corner. */
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -41,6 +43,7 @@ export function MetricCard({
|
|||||||
delta,
|
delta,
|
||||||
deltaDirection,
|
deltaDirection,
|
||||||
emphasis = "default",
|
emphasis = "default",
|
||||||
|
size = "md",
|
||||||
icon,
|
icon,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
@@ -50,6 +53,7 @@ export function MetricCard({
|
|||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
"sui-metric",
|
"sui-metric",
|
||||||
|
`sui-metric--${size}`,
|
||||||
emphasis === "primary" ? "sui-metric--primary" : "",
|
emphasis === "primary" ? "sui-metric--primary" : "",
|
||||||
interactive ? "sui-metric--interactive" : "",
|
interactive ? "sui-metric--interactive" : "",
|
||||||
className ?? "",
|
className ?? "",
|
||||||
|
|||||||
@@ -40,8 +40,53 @@
|
|||||||
.sui-navitem__trailing {
|
.sui-navitem__trailing {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.sui-navitem:focus-visible {
|
.sui-navitem:focus-visible {
|
||||||
outline: 0.125rem solid var(--color-blue);
|
outline: 0.125rem solid var(--color-blue);
|
||||||
outline-offset: 0.125rem;
|
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 type { ReactNode } from "react";
|
||||||
import "@shared/components/NavItem.css";
|
import "@shared/components/NavItem.css";
|
||||||
|
|
||||||
|
export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
|
||||||
|
|
||||||
export interface NavItemProps {
|
export interface NavItemProps {
|
||||||
/** Stable view id passed to the click handler. */
|
/** Stable view id passed to the click handler. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +10,11 @@ export interface NavItemProps {
|
|||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
/** Show the active highlight (navActive background, navActiveText colour). */
|
/** Show the active highlight (navActive background, navActiveText colour). */
|
||||||
isActive?: boolean;
|
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"). */
|
/** Optional trailing badge (e.g. unread count, "new"). */
|
||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
onClick?: (id: string) => void;
|
onClick?: (id: string) => void;
|
||||||
@@ -19,13 +26,14 @@ export interface NavItemProps {
|
|||||||
*
|
*
|
||||||
* Active styling: navActive background, navActiveText colour, weight 500.
|
* Active styling: navActive background, navActiveText colour, weight 500.
|
||||||
* Hover styling: navHover background, navHoverText colour (only when not
|
* 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({
|
export function NavItem({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
isActive,
|
isActive,
|
||||||
|
accent,
|
||||||
trailing,
|
trailing,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
@@ -34,9 +42,15 @@ export function NavItem({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onClick?.(id)}
|
onClick={() => onClick?.(id)}
|
||||||
className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
|
className={[
|
||||||
|
"sui-navitem",
|
||||||
|
isActive ? "is-active" : "",
|
||||||
|
accent ? "sui-navitem--accent" : "",
|
||||||
|
className ?? "",
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
|
data-accent={accent}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@@ -16,16 +16,22 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2rem;
|
flex-shrink: 0;
|
||||||
height: 2rem;
|
width: 1.875rem;
|
||||||
border-radius: var(--radius-md);
|
height: 1.875rem;
|
||||||
color: var(--color-text-3);
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-2);
|
||||||
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background var(--motion-fast),
|
background var(--motion-fast),
|
||||||
color var(--motion-fast);
|
color var(--motion-fast),
|
||||||
|
border-color var(--motion-fast);
|
||||||
}
|
}
|
||||||
.sui-panelhdr__back:hover {
|
.sui-panelhdr__back:hover {
|
||||||
background: var(--color-bg-hover);
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-text-4);
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
.sui-panelhdr__text {
|
.sui-panelhdr__text {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "@shared/components/PanelHeader.css";
|
import "@shared/components/PanelHeader.css";
|
||||||
|
import { IconBadge } from "@shared/components/IconBadge";
|
||||||
|
|
||||||
export interface PanelHeaderProps {
|
export interface PanelHeaderProps {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
@@ -7,6 +8,10 @@ export interface PanelHeaderProps {
|
|||||||
subtitle?: ReactNode;
|
subtitle?: ReactNode;
|
||||||
/** Show a back chevron and trigger this callback when clicked. */
|
/** Show a back chevron and trigger this callback when clicked. */
|
||||||
onBack?: () => void;
|
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. */
|
/** Right-aligned action buttons / chips. */
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -14,12 +19,15 @@ export interface PanelHeaderProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Header strip used by drill-down panels (admin tabs, agent detail, settings
|
* 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({
|
export function PanelHeader({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
onBack,
|
onBack,
|
||||||
|
icon,
|
||||||
|
iconAccent = "blue",
|
||||||
actions,
|
actions,
|
||||||
className,
|
className,
|
||||||
}: PanelHeaderProps) {
|
}: PanelHeaderProps) {
|
||||||
@@ -49,6 +57,11 @@ export function PanelHeader({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{icon && (
|
||||||
|
<IconBadge accent={iconAccent} size="md">
|
||||||
|
{icon}
|
||||||
|
</IconBadge>
|
||||||
|
)}
|
||||||
<div className="sui-panelhdr__text">
|
<div className="sui-panelhdr__text">
|
||||||
<div className="sui-panelhdr__title">{title}</div>
|
<div className="sui-panelhdr__title">{title}</div>
|
||||||
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</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;
|
-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 {
|
.sui-select--sm .sui-select__el {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
padding-left: var(--space-2);
|
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