diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 7a53d8e9c..0797a367a 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,12 +1,14 @@ import { resolve } from "node:path"; import type { StorybookConfig } from "@storybook/react-vite"; +import tsconfigPaths from "vite-tsconfig-paths"; /** * Storybook 9 ships essentials, interactions, and docs as built-ins, so the * addon list is just the extras we want: theme switching + a11y auditing. * - * Story files live next to their components in shared/ and portal/src/. - * MDX docs pages live in portal/src/docs/. + * Story files live next to their components in shared/, portal/src/, and + * editor/src/ — the design system is shared by BOTH apps, so both surface + * their stories here. MDX docs pages live in portal/src/docs/. */ const config: StorybookConfig = { stories: [ @@ -14,6 +16,7 @@ const config: StorybookConfig = { "../portal/src/**/*.stories.@(ts|tsx)", "../shared/**/*.mdx", "../shared/**/*.stories.@(ts|tsx)", + "../editor/src/**/*.stories.@(ts|tsx)", ], addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], framework: { @@ -28,13 +31,26 @@ const config: StorybookConfig = { staticDirs: ["../portal/public"], viteFinal: async (config) => { // Wire @portal/* and @shared/* aliases directly on the Storybook bundler so - // story imports resolve without needing the portal's vite config. + // portal story imports resolve without needing the portal's vite config. config.resolve = config.resolve ?? {}; config.resolve.alias = { ...(config.resolve.alias ?? {}), "@portal": resolve(__dirname, "../portal/src"), "@shared": resolve(__dirname, "../shared"), }; + // Editor stories import via @app/* (proprietary→core fallback), @core/* and + // @proprietary/*. Resolve them exactly the way the editor's own build does — + // through vite-tsconfig-paths against the proprietary vite tsconfig — so the + // shared Storybook can host editor components without duplicating the alias + // map here. + config.plugins = config.plugins ?? []; + config.plugins.push( + tsconfigPaths({ + projects: [ + resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"), + ], + }), + ); return config; }, }; diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 388933960..d06fd55e4 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -8193,6 +8193,9 @@ confirm = "Extract" message = "This ZIP contains {{count}} files. Extract anyway?" title = "Large ZIP File" +[policies] +sidebarTitle = "Policies" + [watchedFolders] alreadyInFolder = "Already in this folder" deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 6249565ff..46644b1b7 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core"; import MoreVertIcon from "@mui/icons-material/MoreVert"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import FolderIcon from "@mui/icons-material/Folder"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; @@ -17,6 +18,7 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import { FileId } from "@app/types/file"; import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; import { useFolders } from "@app/contexts/FolderContext"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { @@ -572,6 +574,31 @@ function FolderCard({ ); } +/** Shield badges for the policies that have run on a file. */ +function PolicyBadges({ fileId }: { fileId: string }) { + const badges = usePolicyFileBadges().get(fileId) ?? []; + if (badges.length === 0) return null; + return ( + + {badges.slice(0, 3).map((policy) => ( + + + + + + ))} + + ); +} + interface FileCardProps { file: StirlingFileStub; isSelected: boolean; @@ -731,6 +758,7 @@ function FileCard({ {fileSize} · {fileDate} +
@@ -1315,6 +1343,7 @@ function FileRow({ )} + {isInWorkspace && ( diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 9b3b7b206..f1e0e3db0 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -484,6 +484,24 @@ gap: 0.4rem; } +/* Policy activity badges (a shield per policy that has run on the file). */ +.files-page-policy-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} +.files-page-policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 4px; + /* `color` set inline to the policy accent; tint follows it. */ + background: color-mix(in srgb, currentColor 16%, transparent); +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { diff --git a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx new file mode 100644 index 000000000..d787c6435 --- /dev/null +++ b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx @@ -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; +} diff --git a/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx new file mode 100644 index 000000000..b34fadc7a --- /dev/null +++ b/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx @@ -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; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index fb02ebf09..ed4e6e75d 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -42,6 +42,7 @@ import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import { useFolderMembership } from "@app/hooks/useFolderMembership"; import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { setWatchedFolderDraggedFileIds, clearWatchedFolderDraggedFileIds, @@ -131,6 +132,7 @@ const FileSidebar = forwardRef( // the workbench). The same map drives the per-file membership dots. const folderMembership = useFolderMembership(); const allFolders = useAllWatchedFolders(); + const policyFileBadges = usePolicyFileBadges(); const folderById = useMemo( () => new Map(allFolders.map((f) => [f.id, f])), [allFolders], @@ -892,6 +894,9 @@ const FileSidebar = forwardRef( onDragStart={handleWatchedFolderDragStart} folders={memberFolders} onFolderClick={openWatchedFolder} + policies={ + policyFileBadges.get(stub.id as string) ?? [] + } /> ); })} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 3529f0165..81ca24ae0 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -134,6 +134,25 @@ text-overflow: ellipsis; } +/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */ +.file-sidebar-policy-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} +.file-sidebar-policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 4px; + /* `color` is set inline to the policy's accent; the tint follows it. */ + color: var(--text-secondary); + background: color-mix(in srgb, currentColor 16%, transparent); +} + /* ---- Folder membership tags ---- */ .file-sidebar-folder-tags { display: flex; diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index c03930864..1b2f566f1 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core"; import { useTranslation } from "react-i18next"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import type { FileId } from "@app/types/file"; import { FileDocIcon } from "@app/components/shared/FileDocIcon"; import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon"; @@ -125,6 +126,14 @@ export interface FileItemFolderRef { accentColor: string; } +/** A policy that has run on this file, used for the activity badges. */ +export interface FileItemPolicyRef { + id: string; + name: string; + /** CSS colour for the badge (matches the policy's accent). */ + accentColor: string; +} + export interface FileItemProps { fileId: FileId; name: string; @@ -143,9 +152,12 @@ export interface FileItemProps { folders?: FileItemFolderRef[]; /** Clicking a membership dot opens that folder. */ onFolderClick?: (folderId: string) => void; + /** Policies that have run on this file — rendered as small shield badges. */ + policies?: FileItemPolicyRef[]; } const MAX_VISIBLE_FOLDER_TAGS = 2; +const MAX_VISIBLE_POLICY_BADGES = 3; export function FileItem({ fileId, @@ -162,6 +174,7 @@ export function FileItem({ onDragStart, folders = [], onFolderClick, + policies = [], }: FileItemProps) { const { t } = useTranslation(); const ext = getFileExtension(name); @@ -237,6 +250,25 @@ export function FileItem({ {dateLabel && typeLabel ? " · " : ""} {typeLabel} + {policies.length > 0 && ( + + {policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => ( + + + + + + ))} + + )} {folders.length > 0 && ( diff --git a/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx b/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx index 25ddffaa7..0e6ba64a2 100644 --- a/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx +++ b/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, ReactNode } from "react"; +import { createContext, useContext, useEffect, ReactNode } from "react"; import { MantineProvider } from "@mantine/core"; import { useRainbowTheme } from "@app/hooks/useRainbowTheme"; import { mantineTheme } from "@app/theme/mantineTheme"; @@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast"; import ToastRenderer from "@app/components/toast/ToastRenderer"; import { ToastPortalBinder } from "@app/components/toast"; import type { ThemeMode } from "@app/constants/theme"; +// SUI shared design-system tokens (used by @shared/components). Additive — its +// var names don't clash with the editor's own theme.css. The effect below +// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on. +import "@shared/tokens/tokens.css"; interface RainbowThemeContextType { themeMode: ThemeMode; @@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { const mantineColorScheme = rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode; + // Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css + // keys its dark palette on (the editor otherwise only sets Mantine's + // data-mantine-color-scheme), so @shared/components theme correctly. + useEffect(() => { + document.documentElement.setAttribute( + "data-theme", + mantineColorScheme === "dark" ? "dark" : "light", + ); + }, [mantineColorScheme]); + return ( { + withViewTransition(() => { + if (readerMode) setReaderMode(false); + setLeftPanelView("toolPicker"); + if (!sidebarsVisible) setSidebarsVisible(true); + setAllToolsView(false); + setSearchQuery(""); + }); + }; + // The header shows [back] [search] when we have somewhere to go back to — // i.e. the user is in a specific tool, or already in the all-tools/search view. const inToolView = leftPanelView !== "toolPicker"; // Show X (close) button only when there's somewhere to go back to. const showCloseButton = inToolView || allToolsView; - // Show search input whenever there's a close button, or when agents are off and - // we're in the default tool-picker view (search filters the full list inline). + // Policies sit above the tool list in the default tool-picker view. + const showPolicies = + policiesEnabled && !allToolsView && leftPanelView === "toolPicker"; + // When Policies are shown, the search moves OUT of the header to sit between + // the Policies and Tools sections (separating them); otherwise it stays in the + // header. Show the header search when there's a close button, or when agents + // are off in the default tool-picker view (inline filtering). + const showInlineSearch = showPolicies && !showCloseButton; const showHeaderSearch = - showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"); + !showInlineSearch && + (showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker")); const handleHeaderBack = () => { if (inToolView) { @@ -201,11 +231,21 @@ export default function RightSidebar() { const showAgents = agentsEnabled && !allToolsView && leftPanelView === "toolPicker"; + // The detail takeover replaces the tool list ONLY in the same default view — + // never over an open tool or the all-tools view (which must keep priority). + // A lingering selection is harmless: it stays hidden behind a tool and the + // list/takeover reappears on return to the picker (as in the prototype). + const policyDetailActive = rawPolicyDetailActive && showPolicies; + + // The rail widens when a policy detail takes it over — the tool list is fine + // at 18.5rem, but the policy detail/wizard/settings need more breathing room. + const expandedWidth = policyDetailActive ? "25rem" : "18.5rem"; + const computedWidth = () => { if (isMobile) return "100%"; if (isChatOpen) return `${chatWidthPx}px`; if (!isPanelVisible) return "3.5rem"; - return "18.5rem"; + return expandedWidth; }; // Collapsed rail: show favourites + recommended tools as icons. @@ -248,6 +288,8 @@ export default function RightSidebar() { ...(isChatDragging ? { transition: "none" } : {}), }} > + {/* Headless: enforces enabled policies on every uploaded file. */} + {policiesEnabled && } {!fullscreenExpanded && isChatOpen && (
@@ -288,6 +330,7 @@ export default function RightSidebar() {
+
{collapsedRailItems.map(({ id, tool }) => ( -
- {activeTool ? ( -
- - - - - {activeTool.name} - -
- ) : showHeaderSearch ? ( -
- + +
+ ) : ( + <> + {!showPolicies && ( +
+ {activeTool ? ( +
+ + + + + {activeTool.name} + +
+ ) : showHeaderSearch ? ( +
+ +
+ ) : ( + showAgents && ( + + {t("agents.section_title", "Agents")} + + ) + )} + {showCloseButton ? ( + + + + ) : ( + + + + )} +
+ )} + + {showAgents && } + + {showPolicies && ( + + + + } /> -
- ) : ( - showAgents && ( - - {t("agents.section_title", "Agents")} - - ) - )} - {showCloseButton ? ( - - - - ) : ( - - - - )} -
+ )} - {showAgents && } + {showInlineSearch && ( +
+ +
+ )} - + + + )}
)} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 592c83771..103a24ad9 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -201,6 +201,13 @@ border-color: var(--border-subtle) !important; } +/* The collapse/expand toggle keeps a stable identity across the collapsed strip + and the expanded header, so a view transition translates it between the two + (same size) instead of cross-fading/scaling it ("big then small"). */ +.tool-panel__toggle-vt { + view-transition-name: sidebar-toggle; +} + .tool-panel__expand-btn svg { color: var(--text-secondary) !important; } @@ -271,6 +278,22 @@ width: 100%; } +/* Search that separates the Policies section from the Tools list below it. */ +.tool-panel__between-search { + padding: 0.5rem 0.75rem 0.25rem; + border-top: 1px solid var(--border-subtle, var(--color-border)); +} + +.tool-panel__between-search .search-input-container { + width: 100%; + margin-bottom: 0; +} + +/* Slightly recessed search field so it reads as distinct from the panel. */ +.tool-panel__between-search input { + background-color: var(--bg-muted); +} + .tool-panel__active-tool-pill { display: inline-flex; align-items: center; diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts index 3a2e3d7a2..0ada1cc67 100644 --- a/frontend/editor/src/core/constants/featureFlags.ts +++ b/frontend/editor/src/core/constants/featureFlags.ts @@ -4,11 +4,18 @@ */ /** - * Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the - * custom workbench registration (seeding, URL sync, view) and the sidebar - * entry point, so the feature is unreachable from the UI. Flip to `true` to - * restore access. + * Watched Folders. Disabled for now — gates both the custom workbench + * registration (seeding, URL sync, view) and the sidebar entry point, so the + * feature is unreachable from the UI. Flip to `true` to restore access. */ // Annotated as `boolean` (not the literal `false`) so call sites aren't treated // as constant/unreachable conditions by the type checker and linter. export const WATCHED_FOLDERS_ENABLED: boolean = false; + +/** + * Policies — a proprietary, automation-backed feature (like Watched Folders but + * backend-driven, with non-folder triggers). The implementation lives under + * `proprietary/`; this core value stays `false` so the shared sidebar entry + * never appears in the open-source build. + */ +export const POLICIES_ENABLED: boolean = false; diff --git a/frontend/editor/src/core/hooks/useAllWatchedFolders.ts b/frontend/editor/src/core/hooks/useAllWatchedFolders.ts index 8abc81a46..b1dbc2f57 100644 --- a/frontend/editor/src/core/hooks/useAllWatchedFolders.ts +++ b/frontend/editor/src/core/hooks/useAllWatchedFolders.ts @@ -17,7 +17,9 @@ export function useAllWatchedFolders(): WatchedFolder[] { useEffect(() => { const load = async () => { try { - setFolders(await watchedFolderStorage.getAllFolders()); + // Policy-owned folders are managed by Policies, not shown here. + const all = await watchedFolderStorage.getAllFolders(); + setFolders(all.filter((f) => !f.policyCategoryId)); } catch (err) { console.error("Failed to load smart folders:", err); } diff --git a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts new file mode 100644 index 000000000..e3297f9bd --- /dev/null +++ b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts @@ -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 { + return new Map(); +} diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts index a8291a181..1618e8fa2 100644 --- a/frontend/editor/src/core/tests/helpers/api-stubs.ts +++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts @@ -248,6 +248,14 @@ export async function mockAppApis( await page.route("**/api/v1/info/wau", (route: Route) => route.fulfill({ json: { count: 0 } }), ); + + // Policies (proprietary): the reconcile fires GET /api/v1/policies on app + // load. Return an empty list so the stubbed (backend-free) env stays clean — + // no failed request polluting the console, and the auto-run controller has + // nothing to dispatch. + await page.route("**/api/v1/policies", (route: Route) => + route.fulfill({ json: [] }), + ); } /** diff --git a/frontend/editor/src/core/types/watchedFolders.ts b/frontend/editor/src/core/types/watchedFolders.ts index adfdc1640..ce8f7374e 100644 --- a/frontend/editor/src/core/types/watchedFolders.ts +++ b/frontend/editor/src/core/types/watchedFolders.ts @@ -25,6 +25,9 @@ export interface WatchedFolder { hasOutputDirectory?: boolean; // true when a local FS output folder is configured /** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */ inputSource?: "idb" | "local-folder"; + /** Set when this folder is the backing store for a Policy (its category id). + * Policy-owned folders are filtered out of the Watched Folders UI. */ + policyCategoryId?: string; } export interface FolderFileMetadata { diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css new file mode 100644 index 000000000..fc6e24324 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -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); +} diff --git a/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx b/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx new file mode 100644 index 000000000..863501000 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx @@ -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 ( +
+ {children} +
+ ); +} + +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: () => ( + + + + ), +}; + +/** Configured policy, paused — amber accent + warning badge. */ +export const DetailPaused: Story = { + render: () => ( + + + + ), +}; + +/** Read-only view for a member without configure permission. */ +export const DetailManaged: Story = { + render: () => ( + + + + ), +}; + +/** The policy list section (above Tools). */ +export const ListSection: Story = { + render: () => ( +
+ +
+ ), +}; + +// 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. diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx new file mode 100644 index 000000000..0eabd502f --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx @@ -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 ? : ; +} + +function renderHost() { + return render( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx new file mode 100644 index 000000000..2028063fe --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -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 ( +
+
+ {leadingControl} + + + + +
+ + {expanded && ( + <> +
+ {categories.map((cat) => { + if (cat.comingSoon) { + return ( +
+ + {cat.icon} + + {cat.label} + + Coming soon + +
+ ); + } + const status = deriveRowStatus(pol.policies[cat.id]); + return ( + + ); + })} +
+ + )} +
+ ); +} + +/** + * 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([]); + const [backingFolder, setBackingFolder] = useState( + null, + ); + const [backingAutomation, setBackingAutomation] = + useState(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 ( + 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 ( +
+
+

Loading…

+
+
+ ); + } + return ( + setPolicyDetailView("detail")} + onComplete={(result) => + pol.savePolicyConfig(selectedId, result).then(() => { + setReloadKey((k) => k + 1); + setPolicyDetailView("detail"); + }) + } + onCommitConfig={commitConfig} + onSetupClassification={onSetupClassification} + /> + ); + } + + return ( + <> + 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 && ( + 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 ( + <> +
+ {categories + .filter((cat) => !cat.comingSoon) + .map((cat) => { + const status = deriveRowStatus(pol.policies[cat.id]); + const suffix = + status === "active" + ? " (Active)" + : status === "paused" + ? " (Paused)" + : ""; + return ( + + + + ); + })} +
+
+ + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx new file mode 100644 index 000000000..7c4e92194 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx @@ -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; +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx new file mode 100644 index 000000000..02b46928c --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx @@ -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 ( + + + + {t( + "policies.deleteConfirmBody", + "This removes the policy and its workflow. Documents already processed are not affected.", + )} + + + + + + + + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx new file mode 100644 index 000000000..bda878299 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx @@ -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 ( + + + {message} + + + + ); +} + +/** 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 ( +
+ + {isPaused ? "Paused" : "Active"} + + } + /> + +
+ {/* Enforces */} +
+

Enforces

+ +
+ +
+
+ + + {config.scopeLabel} + + + + On every upload + +
+
+ + Originals stay untouched • Enforced version saved alongside +
+
+
+ + {/* Recent Activity */} +
+

Recent Activity

+ {activityItems.length > 0 ? ( + + {activityItems.map((item, i) => ( + 0} + leadingTone={ + item.status === "flagged" + ? "warning" + : item.status === "processing" + ? "info" + : "success" + } + leading={ + item.status === "flagged" ? ( + + ) : item.status === "processing" ? ( + + ) : ( + + ) + } + title={item.doc} + description={ + item.status === "flagged" ? ( + + ) : ( + item.action + ) + } + meta={item.time} + trailing={ + item.status === "flagged" && onRetry ? ( + + ) : undefined + } + /> + ))} + + ) : ( + + } + title="No activity yet" + description="Documents will appear here once this policy runs." + /> + + )} +
+ + {/* Stats — one grouped card with divided columns, intentionally + unlabelled for a quiet summary footer. */} + +
+
+ + {statValues.enforced.toLocaleString()} + + Docs enforced +
+
+ {statValues.dataProcessed} + Data processed +
+
+ {statValues.activeFor} + Active +
+
+
+ + {!canConfigure && ( + } + description="Managed by your organization. Contact an admin to change settings." + /> + )} +
+ + {canConfigure && ( +
+ {canDelete && ( + + )} + + +
+ )} +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx new file mode 100644 index 000000000..ff848100c --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx @@ -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 ( +
+
+ {field.label} + {selected.length} selected +
+
+ {(field.options ?? []).map((opt) => ( + toggle(opt)} + > + {opt} + + ))} +
+
+ ); + } + + const control = + field.type === "toggle" ? ( + onChange(checked)} + aria-label={field.label} + /> + ) : field.type === "select" ? ( + onChange(e.target.value)} + aria-label={field.label} + /> + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx new file mode 100644 index 000000000..ed09a0d1d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx @@ -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; + onChange: (parameters: Record) => 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 ( + ({ value: p.value, label: p.label }))} + value={selected} + onChange={handleChange} + disabled={disabled} + clearable + checkIconPosition="right" + /> + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx new file mode 100644 index 000000000..0d203d20d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx @@ -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; + onChange: (parameters: Record) => 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) => + onChange({ ...parameters, mode: "automatic", useRegex: true, ...next }); + + return ( + + + + + + + patch({ wordsToRedact: [...presetWords, ...next] }) + } + disabled={disabled} + /> + + + + patch({ redactColor: value })} + disabled={disabled} + size="sm" + format="hex" + popoverProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }} + /> + + + patch({ customPadding: typeof value === "number" ? value : 0.1 }) + } + min={0} + max={10} + step={0.1} + disabled={disabled} + size="sm" + placeholder="0.1" + /> + + patch({ wholeWordSearch: e.currentTarget.checked })} + disabled={disabled} + size="sm" + /> + + patch({ convertPDFToImage: e.currentTarget.checked })} + disabled={disabled} + size="sm" + /> + + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx new file mode 100644 index 000000000..7078277af --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -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; + /** + * 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; + 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( + initial.sources.length ? initial.sources : ["editor"], + ); + const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0); + const [scopeTypes, setScopeTypes] = useState(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(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( + () => + 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 ( +
+ +
+ +
+
+ ); + } + + 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, + ) => { + 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 }[], + 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 ( +
+ } + /> + } + /> + +
+ +
+ +
+ {saveError && ( + } + 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. */} +
+ {toolChain ? ( + <> +

+ Configure the tools this policy runs on each document. +

+ + + ) : ( + <> +

+ Build the sequence of tools this policy runs on each document. +

+ + + )} +
+ + {step === 2 && ( + <> +

{category.desc}

+ + {config.fields.map((f, i) => ( + + setFieldValues((prev) => ({ ...prev, [f.key]: v })) + } + /> + ))} + + + {/* Real, working output + retry settings (applied by the engine). */} +

Output & retries

+ +
+ + 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" }, + ]} + /> + } + /> +
+
+ setOutputName(e.target.value)} + placeholder="optional" + aria-label="Output name" + /> + } + /> +
+
+ + 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" }, + ]} + /> + } + /> +
+
+ + setMaxRetries(Math.max(0, Number(e.target.value) || 0)) + } + aria-label="Max retries" + /> + } + /> +
+
+ + setRetryDelayMinutes( + Math.max(0, Number(e.target.value) || 0), + ) + } + aria-label="Retry delay minutes" + /> + } + /> +
+
+ + )} + + {/* 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 && ( + <> +

+ Choose where this policy runs and which document types it applies + to. +

+

Sources

+ + {sourceDefs.map((src, i) => ( +
+ toggleSource(src.id)} + leadingIcon={src.icon} + label={src.label} + description={src.desc} + /> +
+ ))} +
+ +

Document types

+ {!classificationEnabled ? ( + } + title="All document types" + description="Enable the Classification policy to filter by document type." + action={ + + } + /> + ) : ( + +
+ + {scopeTypes.length === 0 + ? "All document types" + : `${scopeTypes.length} types selected`} + + +
+ {scopeNarrow && ( +
+ {docTypes.map((dt) => ( + + setScopeTypes((prev) => + prev.includes(dt) + ? prev.filter((d) => d !== dt) + : [...prev, dt], + ) + } + label={dt} + /> + ))} +
+ )} +
+ )} + + )} + + {step === TOTAL_STEPS && ( + <> +

+ When Stirling has low confidence in an enforcement action, it will + send the document for human review. +

+

Reviewer

+ + + setReviewerEmail(e.target.value)} + placeholder="email@company.com" + /> + + + +

Summary

+ +
+ + {category.icon} + + + {category.label} Policy + +
+
+ + + + {SOURCES_IN_FLOW && ( + {sources.length} selected + )} + + {reviewerEmail || Not set} + +
+
+ + )} +
+ +
+ + {step < TOTAL_STEPS ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx new file mode 100644 index 000000000..497cad780 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx @@ -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; +} + +interface PolicyToolConfigProps { + /** The policy's fixed tool chain — locked (no add/remove), only configurable. */ + tools: PolicyToolState[]; + toolRegistry: Partial; + 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) => + onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t))); + + return ( +
+ {tools.map((tool, index) => { + const entry = toolRegistry[tool.operation as ToolId]; + const Settings = entry?.automationSettings ?? null; + return ( + +
+ {entry?.icon} + + {entry?.name ?? tool.operation} + + patchTool(index, { enabled: checked })} + aria-label={`Enable ${entry?.name ?? tool.operation}`} + /> +
+ {tool.enabled && + (tool.operation === "redact" ? ( + // Redact has a bespoke config: PII preset dropdown + a custom + // word/regex field + advanced options, mode + regex locked on. +
+ patchTool(index, { parameters })} + disabled={!editable} + /> +
+ ) : Settings ? ( +
+ }> + + patchTool(index, { + parameters: { ...tool.parameters, [key]: value }, + }) + } + disabled={!editable} + /> + +
+ ) : null)} +
+ ); + })} +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx new file mode 100644 index 000000000..472d1852f --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx @@ -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, + presetParams: Record, + savedParams: Record, +): Record { + const merged: Record = { + ...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 }[], + 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(() => + 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; + return { + operation: op, + enabled: Boolean(saved), + parameters: seedToolParameters( + defaults, + (preset?.parameters ?? {}) as Record, + (saved?.parameters ?? {}) as Record, + ), + }; + }), + ); + + // 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 ( + + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx new file mode 100644 index 000000000..842bf07e8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx @@ -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, + ) => 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 ( + {}} + onComplete={(saved) => onComplete(saved, toolRegistry)} + onSaveFailed={onSaveFailed} + /> + ); +} + +export { AutomationMode }; diff --git a/frontend/editor/src/proprietary/components/policies/README.md b/frontend/editor/src/proprietary/components/policies/README.md new file mode 100644 index 000000000..df6535ba1 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/README.md @@ -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 +`` (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). diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts new file mode 100644 index 000000000..8a62cbfcc --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -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 { + 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 + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts new file mode 100644 index 000000000..77eb931b5 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -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; + 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) { + 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, + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts new file mode 100644 index 000000000..451582b90 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts @@ -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); +} diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts new file mode 100644 index 000000000..d3c3d8ea7 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts @@ -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 = { + 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 = { + ingestion: "blue", + security: "purple", + compliance: "green", + routing: "amber", + retention: "red", +}; diff --git a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts new file mode 100644 index 000000000..9af2fff4d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts @@ -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 = { + // 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; +} diff --git a/frontend/editor/src/proprietary/components/policies/policyValues.ts b/frontend/editor/src/proprietary/components/policies/policyValues.ts new file mode 100644 index 000000000..844156656 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyValues.ts @@ -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 { + const out: Record = {}; + for (const f of config.fields) { + out[f.key] = state.fieldValues[f.key] ?? f.value; + } + return out; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts new file mode 100644 index 000000000..a9407a6d0 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -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>(new Set()); + const importing = useRef>(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, +): Promise { + 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 { + // 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 { + 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; + } +} diff --git a/frontend/editor/src/proprietary/constants/featureFlags.ts b/frontend/editor/src/proprietary/constants/featureFlags.ts index fdb5912b4..e6dd00f6e 100644 --- a/frontend/editor/src/proprietary/constants/featureFlags.ts +++ b/frontend/editor/src/proprietary/constants/featureFlags.ts @@ -6,10 +6,17 @@ */ /** - * Watched Folders (a.k.a. Watched Folders) — a proprietary feature whose - * implementation lives under `proprietary/`. Still disabled for now; flip to - * `true` to surface it in the proprietary build only. The core override stays - * `false`, so the shared sidebar entry point never appears in the open-source - * build (which has no Watched Folders implementation to navigate to). + * Watched Folders — a proprietary feature whose implementation lives under + * `proprietary/`. Still disabled for now; flip to `true` to surface it in the + * proprietary build only. The core override stays `false`, so the shared + * sidebar entry point never appears in the open-source build (which has no + * Watched Folders implementation to navigate to). */ export const WATCHED_FOLDERS_ENABLED: boolean = false; + +/** + * Policies — proprietary, automation-backed policy enforcement. Enabled in the + * proprietary build so it's reachable while in active development (frontend is + * mock/stub-backed; no real server yet). Core stays `false`. + */ +export const POLICIES_ENABLED: boolean = true; diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts new file mode 100644 index 000000000..c3e1e6003 --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts @@ -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); + } + }); +}); diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx new file mode 100644 index 000000000..5151c2c96 --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -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: , + 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: , + desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.", + }, + { + id: "compliance", + label: "Compliance", + icon: , + desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.", + comingSoon: true, + }, + { + id: "routing", + label: "Routing", + icon: , + desc: "Auto-route documents to the right team, folder, or system.", + comingSoon: true, + }, + { + id: "retention", + label: "Retention", + icon: , + 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 = { + 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: , + }, + { + id: "device", + label: "Entire device", + desc: "All PDFs on this machine, retroactively", + icon: , + }, + { + id: "sharepoint", + label: "SharePoint", + desc: "Connected SharePoint libraries", + icon: , + }, + { + id: "dropbox", + label: "Dropbox", + desc: "Connected Dropbox folders", + icon: , + }, + { + id: "gmail", + label: "Gmail", + desc: "PDF attachments in email", + icon: , + }, + { + id: "gdrive", + label: "Google Drive", + desc: "Connected Drive folders", + icon: , + }, +]; + +/** 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", +]; diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts new file mode 100644 index 000000000..6563c48b9 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts @@ -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(), + 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: "reviewer@x.com", + 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( + "reviewer@x.com", + ); + // 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(); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts new file mode 100644 index 000000000..f5fee714e --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -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(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, + }; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts new file mode 100644 index 000000000..9d2bfbfa2 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts @@ -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(), []); +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts new file mode 100644 index 000000000..5f076225f --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -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 = { + 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 { + const runs = usePolicyRuns(); + return useMemo(() => { + const labelById = new Map( + loadPolicyCatalog().categories.map((c) => [c.id, c.label]), + ); + const byFile = new Map(); + 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]); +} diff --git a/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts b/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts index 4f67f4e20..3e599ab89 100644 --- a/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts +++ b/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts @@ -33,8 +33,9 @@ export function useWatchedFolders(): UseWatchedFoldersReturn { const refreshFolders = useCallback(async () => { try { + // Policy-owned folders are managed by Policies, not shown here. const all = await watchedFolderStorage.getAllFolders(); - setFolders(all); + setFolders(all.filter((f) => !f.policyCategoryId)); } catch (error) { console.error("Failed to load smart folders:", error); } finally { diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts new file mode 100644 index 000000000..0cbcb4964 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -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 { + const res = await apiClient.post("/api/v1/policies", policy); + return res.data; +} + +/** List all stored policies. */ +export async function listPolicies(): Promise { + const res = await apiClient.get("/api/v1/policies"); + return res.data; +} + +/** Fetch a stored policy by id. */ +export async function getPolicy(id: string): Promise { + const res = await apiClient.get( + `/api/v1/policies/${encodeURIComponent(id)}`, + ); + return res.data; +} + +/** Delete a stored policy by id. */ +export async function deletePolicy(id: string): Promise { + 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 { + const form = new FormData(); + for (const file of files) form.append("fileInput", file); + const res = await apiClient.post( + `/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 { + const form = new FormData(); + for (const file of files) form.append("fileInput", file); + form.append("json", JSON.stringify(definition)); + const res = await apiClient.post("/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 { + const res = await apiClient.get( + `/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 { + const res = await apiClient.get( + `/api/v1/policies/run/${encodeURIComponent(runId)}`, + ); + return res.data; +} diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts new file mode 100644 index 000000000..4fd9694d9 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -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 +> { + const stored = await policyApi.listPolicies(); + const byCategory = new Map(); + 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 { + 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 { + 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 { + 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 { + await policyApi.deletePolicy(backendId); +} diff --git a/frontend/editor/src/proprietary/services/policyCatalog.ts b/frontend/editor/src/proprietary/services/policyCatalog.ts new file mode 100644 index 000000000..cefe191a8 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyCatalog.ts @@ -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; + /** 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, + }; +} diff --git a/frontend/editor/src/proprietary/services/policyFolders.test.ts b/frontend/editor/src/proprietary/services/policyFolders.test.ts new file mode 100644 index 000000000..1a34a9414 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyFolders.test.ts @@ -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(); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyFolders.ts b/frontend/editor/src/proprietary/services/policyFolders.ts new file mode 100644 index 000000000..7f034f904 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyFolders.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const folder = await watchedFolderStorage.getFolder(folderId); + if (folder) { + await automationStorage.deleteAutomation(folder.automationId); + } + await watchedFolderStorage.deleteFolder(folderId); +} diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts new file mode 100644 index 000000000..0a60feeae --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts @@ -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 { + 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"); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts new file mode 100644 index 000000000..48d2ba2b7 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyLiveData.ts @@ -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()); +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.test.ts b/frontend/editor/src/proprietary/services/policyPipeline.test.ts new file mode 100644 index 000000000..ed4d0a831 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyPipeline.test.ts @@ -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) => + `/api/v1/general/rotate-pdf?angle=${p.angle}`, + }, + }, +} as unknown as Partial; + +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; + + 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: "me@x.com", + 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("me@x.com"); + 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("me@x.com"); + expect(decoded.fieldValues).toEqual({ minConfidence: "80%" }); + expect(decoded.folder).toEqual(samplePolicy.folder); + expect(decoded.automation?.operations).toEqual( + samplePolicy.automation.operations, + ); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts new file mode 100644 index 000000000..4b691e988 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -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; + fileParameters?: Record; +} + +/** Where the run's outputs are delivered. "inline" = return for download. */ +export interface BackendOutputSpec { + type: string; + options: Record; +} + +/** 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; +} + +/** 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, + toolRegistry: Partial, +): 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, +): Record { + 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 = {}; + // 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, + toolRegistry: Partial, +): { definition: BackendPipelineDefinition; unresolved: string[] } { + const unresolved: string[] = []; + const steps: BackendPipelineStep[] = []; + for (const op of automation.operations) { + const parameters = (op.parameters ?? {}) as Record; + 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; + 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; + 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, + ), + }, + }; +} diff --git a/frontend/editor/src/proprietary/services/policyStorage.test.ts b/frontend/editor/src/proprietary/services/policyStorage.test.ts new file mode 100644 index 000000000..dd0622a7c --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyStorage.test.ts @@ -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: "x@y.com" }); + const p = loadPolicies(); + expect(p.security.fieldValues).toEqual({ detectPII: false }); + expect(p.security.reviewerEmail).toBe("x@y.com"); + }); + + it("resetPolicy reverts a category to unconfigured default", () => { + updatePolicy("compliance", { + configured: true, + status: "active", + reviewerEmail: "a@b.com", + }); + 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 + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts new file mode 100644 index 000000000..7aa25c23e --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -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 = "matt@stirlingpdf.com"; + +/** Read the full policy state, seeding + healing any missing categories. */ +export function loadPolicies(): PoliciesByCategory { + let parsed: Partial = {}; + try { + const raw = + typeof localStorage !== "undefined" + ? localStorage.getItem(STORAGE_KEY) + : null; + if (raw) parsed = JSON.parse(raw) as Partial; + } 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, +): 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); +} diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts new file mode 100644 index 000000000..979d69625 --- /dev/null +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -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; + /** + * 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; + +/** + * 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; + 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; + fileParameters?: Record; + }[]; + /** 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; + 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"; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 8caadae68..bb672250d 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -203,6 +203,15 @@ export default defineConfig(async ({ mode }) => { }), compressStaticCopyPlugin(), ], + resolve: { + // Global alias so @shared resolves for ALL importers — including the + // shared components' own `@shared/components/X.css` self-imports, which + // live outside the editor tsconfig scope and so aren't rewritten by + // vite-tsconfig-paths. Required for the editor to consume SUI components. + alias: { + "@shared": path.resolve(__dirname, "../shared"), + }, + }, server: { host: true, // make sure this port matches the devUrl port in tauri.conf.json file diff --git a/frontend/editor/vitest.config.ts b/frontend/editor/vitest.config.ts index 3343f0242..806a4db30 100644 --- a/frontend/editor/vitest.config.ts +++ b/frontend/editor/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; +import path from "node:path"; + +// Global @shared alias so SUI components (and their own `@shared/*` self-imports, +// which live outside the editor tsconfig scope) resolve under test — mirrors the +// resolve.alias in vite.config.ts. +const sharedDir = path.resolve(__dirname, "../shared"); export default defineConfig({ test: { @@ -42,6 +48,9 @@ export default defineConfig({ projects: ["./tsconfig.core.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -60,6 +69,9 @@ export default defineConfig({ projects: ["./tsconfig.proprietary.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -78,6 +90,9 @@ export default defineConfig({ projects: ["./tsconfig.desktop.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -96,6 +111,9 @@ export default defineConfig({ projects: ["./tsconfig.saas.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -114,6 +132,9 @@ export default defineConfig({ projects: ["./tsconfig.prototypes.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, diff --git a/frontend/shared/components/Checkbox.css b/frontend/shared/components/Checkbox.css index a9f8aeb4e..5281cdf5e 100644 --- a/frontend/shared/components/Checkbox.css +++ b/frontend/shared/components/Checkbox.css @@ -75,6 +75,12 @@ display: block; } +.sui-check__icon { + display: inline-flex; + align-items: center; + color: var(--color-text-4); + flex-shrink: 0; +} .sui-check__text { display: flex; flex-direction: column; diff --git a/frontend/shared/components/Checkbox.tsx b/frontend/shared/components/Checkbox.tsx index 245242046..7d6be673d 100644 --- a/frontend/shared/components/Checkbox.tsx +++ b/frontend/shared/components/Checkbox.tsx @@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit< > { label?: ReactNode; description?: ReactNode; + /** Optional glyph shown between the box and the label text. */ + leadingIcon?: ReactNode; /** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */ indeterminate?: boolean; } export const Checkbox = forwardRef( function Checkbox( - { label, description, indeterminate, className, id, ...rest }, + { label, description, leadingIcon, indeterminate, className, id, ...rest }, ref, ) { return ( @@ -57,6 +59,11 @@ export const Checkbox = forwardRef( + {leadingIcon && ( + + {leadingIcon} + + )} {(label || description) && ( {label && {label}} diff --git a/frontend/shared/components/ChipFlow.css b/frontend/shared/components/ChipFlow.css new file mode 100644 index 000000000..13eba56df --- /dev/null +++ b/frontend/shared/components/ChipFlow.css @@ -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; +} diff --git a/frontend/shared/components/ChipFlow.stories.tsx b/frontend/shared/components/ChipFlow.stories.tsx new file mode 100644 index 000000000..5be1c7238 --- /dev/null +++ b/frontend/shared/components/ChipFlow.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChipFlow } from "@shared/components/ChipFlow"; + +const meta: Meta = { + 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; + +export const Pipeline: Story = { args: { separator: "arrow" } }; +export const Plain: Story = { + args: { separator: "none", items: ["HIPAA", "GDPR", "SOC 2"] }, +}; diff --git a/frontend/shared/components/ChipFlow.tsx b/frontend/shared/components/ChipFlow.tsx new file mode 100644 index 000000000..7415387de --- /dev/null +++ b/frontend/shared/components/ChipFlow.tsx @@ -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 ( +
+ {items.map((item, i) => ( + + {i > 0 && separator === "arrow" && ( + + → + + )} + + {item} + + + ))} +
+ ); +} diff --git a/frontend/shared/components/DataRow.css b/frontend/shared/components/DataRow.css new file mode 100644 index 000000000..ab5fbf69a --- /dev/null +++ b/frontend/shared/components/DataRow.css @@ -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; +} diff --git a/frontend/shared/components/DataRow.stories.tsx b/frontend/shared/components/DataRow.stories.tsx new file mode 100644 index 000000000..887d8541f --- /dev/null +++ b/frontend/shared/components/DataRow.stories.tsx @@ -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 = { + title: "Primitives/DataRow", + component: DataRow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Single: Story = { + args: { label: "Reviewer", children: "matt@stirlingpdf.com" }, +}; + +export const Summary: Story = { + render: () => ( + +
+ + + + 3 selected + matt@stirlingpdf.com +
+
+ ), +}; diff --git a/frontend/shared/components/DataRow.tsx b/frontend/shared/components/DataRow.tsx new file mode 100644 index 000000000..93a52377e --- /dev/null +++ b/frontend/shared/components/DataRow.tsx @@ -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 ( +
+ + {label} + +
{children}
+
+ ); +} diff --git a/frontend/shared/components/IconBadge.css b/frontend/shared/components/IconBadge.css new file mode 100644 index 000000000..2a510b282 --- /dev/null +++ b/frontend/shared/components/IconBadge.css @@ -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); +} diff --git a/frontend/shared/components/IconBadge.stories.tsx b/frontend/shared/components/IconBadge.stories.tsx new file mode 100644 index 000000000..8ac7f8d39 --- /dev/null +++ b/frontend/shared/components/IconBadge.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { IconBadge } from "@shared/components/IconBadge"; + +function Glyph() { + return ( + + + + + + ); +} + +const meta: Meta = { + title: "Primitives/IconBadge", + component: IconBadge, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { accent: "blue", size: "md", children: }, + argTypes: { + accent: { + control: "inline-radio", + options: ["blue", "purple", "green", "amber", "red"], + }, + size: { control: "inline-radio", options: ["sm", "md"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Blue: Story = {}; +export const Accents: Story = { + render: () => ( +
+ {(["blue", "purple", "green", "amber", "red"] as const).map((a) => ( + + + + ))} +
+ ), +}; diff --git a/frontend/shared/components/IconBadge.tsx b/frontend/shared/components/IconBadge.tsx new file mode 100644 index 000000000..0968b6459 --- /dev/null +++ b/frontend/shared/components/IconBadge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/shared/components/ListRow.css b/frontend/shared/components/ListRow.css new file mode 100644 index 000000000..8317b76fc --- /dev/null +++ b/frontend/shared/components/ListRow.css @@ -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; +} diff --git a/frontend/shared/components/ListRow.stories.tsx b/frontend/shared/components/ListRow.stories.tsx new file mode 100644 index 000000000..5aa7f7e97 --- /dev/null +++ b/frontend/shared/components/ListRow.stories.tsx @@ -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 = { + title: "Primitives/ListRow", + component: ListRow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function Dot({ ch }: { ch: string }) { + return {ch}; +} + +export const Default: Story = { + args: { + leading: , + 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: () => ( + + } + leadingTone="success" + title="MSA_Acme_2026.pdf" + description="Classified as Contract • 3 tables extracted" + meta="2h ago" + /> + } + leadingTone="warning" + title="scan_002.pdf" + description="Low confidence (62%) • flagged for review" + meta="Yesterday" + trailing={ + + Flagged + + } + /> + } + leadingTone="success" + title="Invoice_4471.pdf" + description="Classified as Invoice • renamed to standard" + meta="5h ago" + /> + + ), +}; + +export const Interactive: Story = { + args: { + leading: , + leadingTone: "info", + title: "Clickable row", + description: "The whole row is a button", + onClick: () => {}, + }, +}; diff --git a/frontend/shared/components/ListRow.tsx b/frontend/shared/components/ListRow.tsx new file mode 100644 index 000000000..098df7850 --- /dev/null +++ b/frontend/shared/components/ListRow.tsx @@ -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 && ( + + {leading} + + )} + + {title} + {description && ( + {description} + )} + {meta && {meta}} + + {trailing && {trailing}} + + ); + + return onClick ? ( + + ) : ( +
{content}
+ ); +} diff --git a/frontend/shared/components/MetricCard.css b/frontend/shared/components/MetricCard.css index 03cc4c154..59c2b4500 100644 --- a/frontend/shared/components/MetricCard.css +++ b/frontend/shared/components/MetricCard.css @@ -17,6 +17,18 @@ color: inherit; text-align: left; } +/* Compact density for narrow rails / metric strips. */ +.sui-metric--sm { + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + min-width: 0; +} +.sui-metric--sm .sui-metric__value { + font-size: 1.125rem; +} +.sui-metric--sm .sui-metric__label { + white-space: nowrap; +} .sui-metric--primary { background: var(--color-bg-subtle); } diff --git a/frontend/shared/components/MetricCard.tsx b/frontend/shared/components/MetricCard.tsx index 9771a9821..1d357932f 100644 --- a/frontend/shared/components/MetricCard.tsx +++ b/frontend/shared/components/MetricCard.tsx @@ -14,6 +14,8 @@ export interface MetricCardProps { deltaDirection?: DeltaDirection; /** Visual emphasis. `primary` = darker surface, used for hero metrics. */ emphasis?: "default" | "primary"; + /** Density. `sm` tightens padding + value size for narrow rails/strips. */ + size?: "sm" | "md"; /** Optional icon shown in the top-right corner. */ icon?: ReactNode; onClick?: () => void; @@ -41,6 +43,7 @@ export function MetricCard({ delta, deltaDirection, emphasis = "default", + size = "md", icon, onClick, className, @@ -50,6 +53,7 @@ export function MetricCard({ const classes = [ "sui-metric", + `sui-metric--${size}`, emphasis === "primary" ? "sui-metric--primary" : "", interactive ? "sui-metric--interactive" : "", className ?? "", diff --git a/frontend/shared/components/NavItem.css b/frontend/shared/components/NavItem.css index d70c11378..982abbc26 100644 --- a/frontend/shared/components/NavItem.css +++ b/frontend/shared/components/NavItem.css @@ -40,8 +40,53 @@ .sui-navitem__trailing { display: inline-flex; align-items: center; + margin-left: auto; } .sui-navitem:focus-visible { outline: 0.125rem solid var(--color-blue); outline-offset: 0.125rem; } + +/* ---- Status accent (optional) ---- */ +.sui-navitem--accent { + position: relative; +} +.sui-navitem--accent::before { + content: ""; + position: absolute; + left: 0; + top: 0.3125rem; + bottom: 0.3125rem; + width: 3px; + border-radius: 3px; +} +.sui-navitem[data-accent="blue"]::before { + background: var(--color-blue); +} +.sui-navitem[data-accent="purple"]::before { + background: var(--color-purple); +} +.sui-navitem[data-accent="green"]::before { + background: var(--color-green); +} +.sui-navitem[data-accent="amber"]::before { + background: var(--color-amber); +} +.sui-navitem[data-accent="red"]::before { + background: var(--color-red); +} +.sui-navitem[data-accent="blue"] .sui-navitem__icon { + color: var(--color-blue); +} +.sui-navitem[data-accent="purple"] .sui-navitem__icon { + color: var(--color-purple); +} +.sui-navitem[data-accent="green"] .sui-navitem__icon { + color: var(--color-green); +} +.sui-navitem[data-accent="amber"] .sui-navitem__icon { + color: var(--color-amber); +} +.sui-navitem[data-accent="red"] .sui-navitem__icon { + color: var(--color-red); +} diff --git a/frontend/shared/components/NavItem.tsx b/frontend/shared/components/NavItem.tsx index fd328bc0f..05826e4b6 100644 --- a/frontend/shared/components/NavItem.tsx +++ b/frontend/shared/components/NavItem.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import "@shared/components/NavItem.css"; +export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red"; + export interface NavItemProps { /** Stable view id passed to the click handler. */ id: string; @@ -8,6 +10,11 @@ export interface NavItemProps { icon?: ReactNode; /** Show the active highlight (navActive background, navActiveText colour). */ isActive?: boolean; + /** + * Optional status accent: draws a left edge bar in the tone colour and tints + * the leading icon to match. For listing live/paused/etc. entities. + */ + accent?: NavItemAccent; /** Optional trailing badge (e.g. unread count, "new"). */ trailing?: ReactNode; onClick?: (id: string) => void; @@ -19,13 +26,14 @@ export interface NavItemProps { * * Active styling: navActive background, navActiveText colour, weight 500. * Hover styling: navHover background, navHoverText colour (only when not - * already active). + * already active). An optional `accent` adds a status edge bar + icon tint. */ export function NavItem({ id, label, icon, isActive, + accent, trailing, onClick, className, @@ -34,9 +42,15 @@ export function NavItem({ )} + {icon && ( + + {icon} + + )}
{title}
{subtitle &&
{subtitle}
} diff --git a/frontend/shared/components/SectionHeader.css b/frontend/shared/components/SectionHeader.css new file mode 100644 index 000000000..63a354cc2 --- /dev/null +++ b/frontend/shared/components/SectionHeader.css @@ -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); +} diff --git a/frontend/shared/components/SectionHeader.stories.tsx b/frontend/shared/components/SectionHeader.stories.tsx new file mode 100644 index 000000000..d3adffd1c --- /dev/null +++ b/frontend/shared/components/SectionHeader.stories.tsx @@ -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 = { + title: "Primitives/SectionHeader", + component: SectionHeader, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Static: Story = { args: { title: "Policies", count: "3 active" } }; + +export const Collapsible: Story = { + render: () => { + const [open, setOpen] = useState(true); + return ( + setOpen((v) => !v)} + /> + ); + }, +}; diff --git a/frontend/shared/components/SectionHeader.tsx b/frontend/shared/components/SectionHeader.tsx new file mode 100644 index 000000000..257873093 --- /dev/null +++ b/frontend/shared/components/SectionHeader.tsx @@ -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 = ( + <> + {title} + {count != null && {count}} + {collapsible && ( + + + + )} + + ); + + return collapsible ? ( + + ) : ( +
{inner}
+ ); +} diff --git a/frontend/shared/components/Select.css b/frontend/shared/components/Select.css index f97bf0f58..b01cf2ecd 100644 --- a/frontend/shared/components/Select.css +++ b/frontend/shared/components/Select.css @@ -44,6 +44,25 @@ -webkit-appearance: none; } +/* + * The native option popup is OS-rendered: without explicit colours it falls + * back to the UA scheme (white) while the option text inherits the themed + * (light) colour — i.e. white-on-white in dark mode wherever the surrounding + * app keeps color-scheme: light (e.g. the editor, whose dark mode is driven by + * Mantine, not color-scheme). Theme the options explicitly and pin the + * select's color-scheme to the active SUI theme so the popup is always legible. + */ +.sui-select__el option { + background-color: var(--color-surface); + color: var(--color-text-1); +} +[data-theme="dark"] .sui-select__el { + color-scheme: dark; +} +[data-theme="light"] .sui-select__el { + color-scheme: light; +} + .sui-select--sm .sui-select__el { font-size: 0.8125rem; padding-left: var(--space-2); diff --git a/frontend/shared/components/SettingsRow.css b/frontend/shared/components/SettingsRow.css new file mode 100644 index 000000000..f374a5fda --- /dev/null +++ b/frontend/shared/components/SettingsRow.css @@ -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; +} diff --git a/frontend/shared/components/SettingsRow.stories.tsx b/frontend/shared/components/SettingsRow.stories.tsx new file mode 100644 index 000000000..964a92a1e --- /dev/null +++ b/frontend/shared/components/SettingsRow.stories.tsx @@ -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 = { + title: "Primitives/SettingsRow", + component: SettingsRow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Toggle: Story = { + args: { + label: "Auto-classify", + control: {}} size="sm" />, + }, +}; + +export const WithDescription: Story = { + args: { + label: "Detect PII", + description: "Scan documents for sensitive fields on save", + control: {}} size="sm" />, + }, +}; + +/** A settings list inside a padding-none Card. */ +export const List: Story = { + render: () => ( + + {[ + { label: "Auto-classify", on: true }, + { label: "Extract tables", on: true }, + { label: "Strip blank pages", on: false }, + ].map((r, i) => ( +
0 ? "1px solid var(--color-border)" : undefined, + }} + > + {}} size="sm" /> + } + /> +
+ ))} +
+ ), +}; + +export const SelectControl: Story = { + args: { + label: "OCR level", + control: ( +