mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Portal policies (#6852)
This commit is contained in:
@@ -95,6 +95,7 @@ tasks:
|
|||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORTAL_PORT}}'
|
PORT: '{{.PORTAL_PORT}}'
|
||||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
|
MOCKS: 'false'
|
||||||
OPEN: "true"
|
OPEN: "true"
|
||||||
|
|
||||||
dev:portal:all:
|
dev:portal:all:
|
||||||
@@ -117,6 +118,32 @@ tasks:
|
|||||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||||
|
MOCKS: 'false'
|
||||||
|
OPEN: "true"
|
||||||
|
- task: frontend:dev
|
||||||
|
vars:
|
||||||
|
PORT: '{{.EDITOR_PORT}}'
|
||||||
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
|
|
||||||
|
dev:portal:all:saas:
|
||||||
|
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
|
||||||
|
vars:
|
||||||
|
PORTS:
|
||||||
|
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||||
|
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||||
|
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||||
|
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||||
|
deps:
|
||||||
|
- task: backend:dev:saas
|
||||||
|
vars:
|
||||||
|
PORT: '{{.BACKEND_PORT}}'
|
||||||
|
POLICIES_ENABLED: "true"
|
||||||
|
- task: frontend:dev:portal
|
||||||
|
vars:
|
||||||
|
PORT: '{{.PORTAL_PORT}}'
|
||||||
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
|
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||||
|
MOCKS: 'false'
|
||||||
OPEN: "true"
|
OPEN: "true"
|
||||||
- task: frontend:dev
|
- task: frontend:dev
|
||||||
vars:
|
vars:
|
||||||
|
|||||||
@@ -212,8 +212,12 @@ export function usePolicyAutoRun(): void {
|
|||||||
s.configured &&
|
s.configured &&
|
||||||
s.status === "active" &&
|
s.status === "active" &&
|
||||||
s.backendId &&
|
s.backendId &&
|
||||||
// Only auto-run on upload when the policy is set to run on upload
|
// Only enforce in the editor when the policy includes "editor" as a source.
|
||||||
// (export-triggered policies enforce at export time instead).
|
// runOn is an editor-specific parameter: "upload" fires here, "export" fires
|
||||||
|
// at export time via policyExport. Non-editor sources have their own triggers.
|
||||||
|
(!s.sources ||
|
||||||
|
s.sources.length === 0 ||
|
||||||
|
s.sources.includes("editor")) &&
|
||||||
(s.runOn ?? "upload") === "upload",
|
(s.runOn ?? "upload") === "upload",
|
||||||
);
|
);
|
||||||
for (const [categoryId, s] of active) {
|
for (const [categoryId, s] of active) {
|
||||||
@@ -275,6 +279,7 @@ export function usePolicyAutoRun(): void {
|
|||||||
// input file it ran on (needs that input's stub, still in the workspace).
|
// input file it ran on (needs that input's stub, still in the workspace).
|
||||||
const outputMode = policies[run.categoryId]?.outputMode ?? "new_version";
|
const outputMode = policies[run.categoryId]?.outputMode ?? "new_version";
|
||||||
const outputName = policies[run.categoryId]?.outputName ?? "";
|
const outputName = policies[run.categoryId]?.outputName ?? "";
|
||||||
|
const outputNamePosition = policies[run.categoryId]?.outputNamePosition;
|
||||||
const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId);
|
const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId);
|
||||||
void importOutputs(run, {
|
void importOutputs(run, {
|
||||||
addFiles,
|
addFiles,
|
||||||
@@ -282,6 +287,7 @@ export function usePolicyAutoRun(): void {
|
|||||||
bumpRevision,
|
bumpRevision,
|
||||||
outputMode,
|
outputMode,
|
||||||
outputName,
|
outputName,
|
||||||
|
outputNamePosition,
|
||||||
parentStub,
|
parentStub,
|
||||||
}).finally(() => importing.current.delete(run.runId));
|
}).finally(() => importing.current.delete(run.runId));
|
||||||
}
|
}
|
||||||
@@ -314,9 +320,11 @@ interface ImportContext {
|
|||||||
bumpRevision: () => void;
|
bumpRevision: () => void;
|
||||||
/** "new_file" adds the output as a separate file; "new_version" versions the input. */
|
/** "new_file" adds the output as a separate file; "new_version" versions the input. */
|
||||||
outputMode: "new_file" | "new_version";
|
outputMode: "new_file" | "new_version";
|
||||||
/** Rename rule. Empty → keep the input's filename; set → use the policy's
|
/** Rename rule. Empty → keep the input's filename. */
|
||||||
* renamed output (applied server-side per the name-position setting). */
|
|
||||||
outputName: string;
|
outputName: string;
|
||||||
|
/** Where the rename is applied: before ("prefix") or after ("suffix") the
|
||||||
|
* base filename. Defaults to "suffix" when absent. */
|
||||||
|
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||||
/** The input file's stub — required to version it; absent if it's been removed. */
|
/** The input file's stub — required to version it; absent if it's been removed. */
|
||||||
parentStub: StirlingFileStub | undefined;
|
parentStub: StirlingFileStub | undefined;
|
||||||
}
|
}
|
||||||
@@ -327,6 +335,20 @@ interface ImportContext {
|
|||||||
* don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we
|
* don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we
|
||||||
* can't map to a configured category are skipped.
|
* can't map to a configured category are skipped.
|
||||||
*/
|
*/
|
||||||
|
function applyOutputName(
|
||||||
|
inputFileName: string,
|
||||||
|
outputName: string,
|
||||||
|
position: "prefix" | "suffix" | "auto-number",
|
||||||
|
): string {
|
||||||
|
const dot = inputFileName.lastIndexOf(".");
|
||||||
|
const base = dot > 0 ? inputFileName.slice(0, dot) : inputFileName;
|
||||||
|
const ext = dot > 0 ? inputFileName.slice(dot) : "";
|
||||||
|
if (position === "suffix") return `${base}_${outputName}${ext}`;
|
||||||
|
if (position === "prefix") return `${outputName}_${base}${ext}`;
|
||||||
|
// auto-number requires dedup state not available here — fall back to suffix.
|
||||||
|
return `${base}_${outputName}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function reconcileServerRuns(
|
async function reconcileServerRuns(
|
||||||
policies: PoliciesByCategory,
|
policies: PoliciesByCategory,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -406,7 +428,11 @@ async function importOutputs(
|
|||||||
// rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would
|
// rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would
|
||||||
// otherwise rename every output.
|
// otherwise rename every output.
|
||||||
const targetName = ctx.outputName
|
const targetName = ctx.outputName
|
||||||
? undefined // use the run's per-output (renamed) name below
|
? applyOutputName(
|
||||||
|
run.fileName,
|
||||||
|
ctx.outputName,
|
||||||
|
ctx.outputNamePosition ?? "suffix",
|
||||||
|
)
|
||||||
: run.fileName;
|
: run.fileName;
|
||||||
const settled = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
pending.map(async (out) => {
|
pending.map(async (out) => {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function usePolicies() {
|
|||||||
reviewerEmail: result.reviewerEmail,
|
reviewerEmail: result.reviewerEmail,
|
||||||
outputMode: result.folder.outputMode,
|
outputMode: result.folder.outputMode,
|
||||||
outputName: result.folder.outputName,
|
outputName: result.folder.outputName,
|
||||||
|
outputNamePosition: result.folder.outputNamePosition,
|
||||||
runOn: result.folder.runOn,
|
runOn: result.folder.runOn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -170,6 +171,7 @@ export function usePolicies() {
|
|||||||
reviewerEmail: result.reviewerEmail,
|
reviewerEmail: result.reviewerEmail,
|
||||||
outputMode: result.folder.outputMode,
|
outputMode: result.folder.outputMode,
|
||||||
outputName: result.folder.outputName,
|
outputName: result.folder.outputName,
|
||||||
|
outputNamePosition: result.folder.outputNamePosition,
|
||||||
runOn: result.folder.runOn,
|
runOn: result.folder.runOn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -234,6 +236,7 @@ export function usePolicies() {
|
|||||||
reviewerEmail: result.reviewerEmail,
|
reviewerEmail: result.reviewerEmail,
|
||||||
outputMode: result.folder.outputMode,
|
outputMode: result.folder.outputMode,
|
||||||
outputName: result.folder.outputName,
|
outputName: result.folder.outputName,
|
||||||
|
outputNamePosition: result.folder.outputNamePosition,
|
||||||
runOn: result.folder.runOn,
|
runOn: result.folder.runOn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function decodedToState(
|
|||||||
fieldValues: decoded.fieldValues,
|
fieldValues: decoded.fieldValues,
|
||||||
outputMode: decoded.folder.outputMode,
|
outputMode: decoded.folder.outputMode,
|
||||||
outputName: decoded.folder.outputName,
|
outputName: decoded.folder.outputName,
|
||||||
|
outputNamePosition: decoded.folder.outputNamePosition,
|
||||||
runOn: decoded.folder.runOn,
|
runOn: decoded.folder.runOn,
|
||||||
folderId: localFolderId,
|
folderId: localFolderId,
|
||||||
backendId: decoded.id,
|
backendId: decoded.id,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function activeExportPolicies(): ExportPolicy[] {
|
|||||||
s.configured &&
|
s.configured &&
|
||||||
s.status === "active" &&
|
s.status === "active" &&
|
||||||
s.backendId &&
|
s.backendId &&
|
||||||
|
(s.sources.length === 0 || s.sources.includes("editor")) &&
|
||||||
s.runOn === "export",
|
s.runOn === "export",
|
||||||
)
|
)
|
||||||
.map(([id, s]) => ({
|
.map(([id, s]) => ({
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ export interface PolicyState {
|
|||||||
* input's filename; when set, it's applied as a prefix/suffix per the policy's
|
* input's filename; when set, it's applied as a prefix/suffix per the policy's
|
||||||
* name-position setting. */
|
* name-position setting. */
|
||||||
outputName?: string;
|
outputName?: string;
|
||||||
|
/** Whether the rename rule is applied before ("prefix") or after ("suffix")
|
||||||
|
* the base filename, or as an auto-incrementing number. */
|
||||||
|
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||||
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */
|
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */
|
||||||
runOn?: "upload" | "export";
|
runOn?: "upload" | "export";
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ label = "Read mode"
|
|||||||
consume = "Consume: process each file once"
|
consume = "Consume: process each file once"
|
||||||
snapshot = "Snapshot: re-read the folder every run"
|
snapshot = "Snapshot: re-read the folder every run"
|
||||||
|
|
||||||
|
[sources.types.editor]
|
||||||
|
label = "Editor"
|
||||||
|
|
||||||
[sources.types.unknown]
|
[sources.types.unknown]
|
||||||
label = "Source"
|
label = "Source"
|
||||||
|
|
||||||
@@ -696,6 +699,11 @@ description = "{{file}} is signed and ready to transfer. It activates one instan
|
|||||||
title = "Policies"
|
title = "Policies"
|
||||||
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
|
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
|
||||||
|
|
||||||
|
[policies.offline]
|
||||||
|
title = "Backend unavailable"
|
||||||
|
description = "Your policies are saved and will appear once the connection is restored."
|
||||||
|
retry = "Retry"
|
||||||
|
|
||||||
[policies.status]
|
[policies.status]
|
||||||
active = "Active"
|
active = "Active"
|
||||||
paused = "Paused"
|
paused = "Paused"
|
||||||
@@ -724,15 +732,17 @@ description = "Across active policies"
|
|||||||
[policies.card]
|
[policies.card]
|
||||||
comingSoon = "Coming soon"
|
comingSoon = "Coming soon"
|
||||||
notSetUp = "Not set up"
|
notSetUp = "Not set up"
|
||||||
setUp = "Set up →"
|
|
||||||
|
|
||||||
[policies.detail]
|
[policies.detail]
|
||||||
title = "{{category}} policy"
|
|
||||||
meta = "Runs on {{event}} · output {{output}}"
|
|
||||||
outputAsNewFile = "as a new file"
|
outputAsNewFile = "as a new file"
|
||||||
outputAsNewVersion = "as a new version"
|
outputAsNewVersion = "as a new version"
|
||||||
enforces = "Enforces"
|
enforces = "Enforces"
|
||||||
enforceNote = "{{scope}} · originals stay untouched, the enforced version is saved alongside."
|
sources = "Sources"
|
||||||
|
onEveryUpload = "On every upload"
|
||||||
|
onEveryExport = "On every export"
|
||||||
|
showMore = "Show more"
|
||||||
|
showLess = "Show less"
|
||||||
|
retry = "Retry"
|
||||||
recentActivity = "Recent activity"
|
recentActivity = "Recent activity"
|
||||||
|
|
||||||
[policies.detail.actions]
|
[policies.detail.actions]
|
||||||
@@ -746,10 +756,6 @@ editSettings = "Edit settings"
|
|||||||
title = "No activity yet"
|
title = "No activity yet"
|
||||||
description = "Documents will appear here once this policy runs."
|
description = "Documents will appear here once this policy runs."
|
||||||
|
|
||||||
[policies.detail.scoped]
|
|
||||||
title = "Scoped"
|
|
||||||
description = "Limited to: {{types}}"
|
|
||||||
|
|
||||||
[policies.wizard.title]
|
[policies.wizard.title]
|
||||||
edit = "Edit {{category}} policy"
|
edit = "Edit {{category}} policy"
|
||||||
setUp = "Set up {{category}} policy"
|
setUp = "Set up {{category}} policy"
|
||||||
@@ -778,6 +784,9 @@ heading = "Settings"
|
|||||||
|
|
||||||
[policies.wizard.sources]
|
[policies.wizard.sources]
|
||||||
heading = "Sources"
|
heading = "Sources"
|
||||||
|
loading = "Loading sources…"
|
||||||
|
emptyTitle = "No sources available"
|
||||||
|
emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here."
|
||||||
|
|
||||||
[policies.wizard.docTypes]
|
[policies.wizard.docTypes]
|
||||||
heading = "Document types"
|
heading = "Document types"
|
||||||
@@ -809,9 +818,10 @@ suffix = "Suffix"
|
|||||||
autoNumber = "Auto-number"
|
autoNumber = "Auto-number"
|
||||||
placeholder = "Text to add (optional)"
|
placeholder = "Text to add (optional)"
|
||||||
|
|
||||||
[policies.wizard.output.reviewerEmail]
|
[policies.wizard.output.retries]
|
||||||
label = "Reviewer email"
|
heading = "Retries"
|
||||||
helper = "Low-confidence enforcements are routed here for review."
|
maxLabel = "Max retries"
|
||||||
|
delayLabel = "Retry delay (min)"
|
||||||
|
|
||||||
[users.summary]
|
[users.summary]
|
||||||
members = "Members"
|
members = "Members"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
* entitlement calls. It never enters the portal — the browser is the human
|
* entitlement calls. It never enters the portal — the browser is the human
|
||||||
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
||||||
*/
|
*/
|
||||||
import { getStoredToken } from "@shared/auth";
|
import { clearStoredToken, getStoredToken } from "@shared/auth";
|
||||||
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||||
|
|
||||||
@@ -156,6 +156,12 @@ async function localJson<T>(
|
|||||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Stale or invalid JWT — clear it so the auth provider re-initialises and
|
||||||
|
// shows the login screen rather than leaving the user stuck with a banner.
|
||||||
|
clearStoredToken();
|
||||||
|
window.dispatchEvent(new CustomEvent("jwt-available"));
|
||||||
|
}
|
||||||
return unwrap<T>(res);
|
return unwrap<T>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,173 @@
|
|||||||
import { apiClient } from "@portal/api/http";
|
|
||||||
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policies service layer — the backend contract.
|
* Policies service layer.
|
||||||
*
|
*
|
||||||
* Unlike every other portal surface (which use the mock `/v1/...` base), this
|
* The portal calls the real Stirling policy API (`/api/v1/policies`). MSW
|
||||||
* one calls the REAL Stirling policy API base `/api/v1/policies` so it is
|
* intercepts these calls in dev/Storybook; dropping MSW is enough to hit the
|
||||||
* genuinely plug-and-play: drop MSW and these exact calls hit the live backend
|
* live backend — no call-site changes needed.
|
||||||
* (PolicyController). The list response is the portal's catalogue shape; the
|
*
|
||||||
* single-policy / create / delete / run calls match the backend records.
|
* `fetchPolicies()` assembles the decorated catalogue client-side from the
|
||||||
|
* backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same
|
||||||
|
* approach the editor uses for its own catalogue view.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "@portal/api/http";
|
||||||
|
import { fromWirePolicy, toWirePolicy } from "@shared/policies/codec";
|
||||||
|
import { runsToActivity, runsToStats } from "@shared/policies/runs";
|
||||||
|
import type { PolicyDecodedState, WirePolicy } from "@shared/policies/types";
|
||||||
|
import {
|
||||||
|
POLICY_CATEGORIES,
|
||||||
|
POLICY_CONFIG,
|
||||||
|
type CatalogueEntry,
|
||||||
|
type DecoratedPolicy,
|
||||||
|
type PoliciesResponse,
|
||||||
|
type PoliciesSummary,
|
||||||
|
type PolicySetupResult,
|
||||||
|
type PolicyState,
|
||||||
|
type PolicyStatus,
|
||||||
|
} from "@portal/mocks/policies";
|
||||||
|
import type { PolicyRunView } from "@shared/policies/types";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CatalogueEntry,
|
CatalogueEntry,
|
||||||
DecoratedPolicy,
|
DecoratedPolicy,
|
||||||
InputSpec,
|
|
||||||
OutputSpec,
|
|
||||||
PipelineStep,
|
|
||||||
PoliciesResponse,
|
PoliciesResponse,
|
||||||
PoliciesSummary,
|
PoliciesSummary,
|
||||||
Policy,
|
|
||||||
PolicyActivityItem,
|
|
||||||
PolicyCategory,
|
PolicyCategory,
|
||||||
PolicyConfigDef,
|
PolicyConfigDef,
|
||||||
|
PolicyDecodedState,
|
||||||
PolicyField,
|
PolicyField,
|
||||||
PolicyFieldType,
|
PolicyFieldType,
|
||||||
PolicyRowStatus,
|
PolicyRowStatus,
|
||||||
|
PolicyRunView,
|
||||||
PolicySetupResult,
|
PolicySetupResult,
|
||||||
PolicySource,
|
|
||||||
PolicyState,
|
PolicyState,
|
||||||
PolicyStats,
|
PolicyStats,
|
||||||
|
PolicyActivityItem,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
TriggerConfig,
|
WirePolicy,
|
||||||
|
WireOutputOptions,
|
||||||
|
WireOutputSpec,
|
||||||
} from "@portal/mocks/policies";
|
} from "@portal/mocks/policies";
|
||||||
export {
|
export {
|
||||||
ENDPOINT_LABELS,
|
ENDPOINT_LABELS,
|
||||||
POLICY_CATEGORIES,
|
POLICY_CATEGORIES,
|
||||||
POLICY_CONFIG,
|
POLICY_CONFIG,
|
||||||
POLICY_DOC_TYPES,
|
POLICY_DOC_TYPES,
|
||||||
POLICY_SOURCES,
|
|
||||||
TOOL_ENDPOINTS,
|
TOOL_ENDPOINTS,
|
||||||
humanizeEndpoint,
|
humanizeEndpoint,
|
||||||
} from "@portal/mocks/policies";
|
} from "@portal/mocks/policies";
|
||||||
|
|
||||||
/** GET /api/v1/policies — the catalogue + every configured policy. */
|
// Re-export the wire step type under the legacy name components depend on.
|
||||||
|
export type { WirePipelineStep as PipelineStep } from "@shared/policies/types";
|
||||||
|
|
||||||
|
// ── Client-side catalogue assembly ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function decoratePolicy(
|
||||||
|
decoded: PolicyDecodedState,
|
||||||
|
runs: PolicyRunView[],
|
||||||
|
isDefault: boolean,
|
||||||
|
): DecoratedPolicy | null {
|
||||||
|
const category = POLICY_CATEGORIES.find((c) => c.id === decoded.categoryId);
|
||||||
|
const config = POLICY_CONFIG[decoded.categoryId];
|
||||||
|
if (!category || !config) return null;
|
||||||
|
|
||||||
|
const policyRuns = runs.filter((r) => r.policyId === decoded.id);
|
||||||
|
const status: PolicyStatus = decoded.enabled ? "active" : "paused";
|
||||||
|
const state: PolicyState = {
|
||||||
|
configured: true,
|
||||||
|
status,
|
||||||
|
sources: decoded.sources,
|
||||||
|
scopeTypes: decoded.scopeTypes,
|
||||||
|
reviewerEmail: decoded.reviewerEmail,
|
||||||
|
fieldValues: decoded.fieldValues,
|
||||||
|
outputMode: decoded.outputMode,
|
||||||
|
outputName: decoded.outputName,
|
||||||
|
outputNamePosition: decoded.outputNamePosition,
|
||||||
|
runOn: decoded.runOn,
|
||||||
|
maxRetries: decoded.maxRetries,
|
||||||
|
retryDelayMinutes: decoded.retryDelayMinutes,
|
||||||
|
backendId: decoded.id,
|
||||||
|
isDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
steps: decoded.steps,
|
||||||
|
stats: runsToStats(policyRuns),
|
||||||
|
activity: runsToActivity(policyRuns),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */
|
||||||
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
||||||
return apiClient.local.json<PoliciesResponse>("/api/v1/policies");
|
const [wirePolicies, runs] = await Promise.all([
|
||||||
|
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
|
||||||
|
apiClient.local
|
||||||
|
.json<PolicyRunView[]>("/api/v1/policies/runs")
|
||||||
|
.catch(() => [] as PolicyRunView[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const decodedByCategory = new Map<
|
||||||
|
string,
|
||||||
|
{ decoded: PolicyDecodedState; isDefault: boolean }
|
||||||
|
>();
|
||||||
|
for (const wire of wirePolicies) {
|
||||||
|
const decoded = fromWirePolicy(wire);
|
||||||
|
if (decoded.categoryId) {
|
||||||
|
decodedByCategory.set(decoded.categoryId, { decoded, isDefault: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => {
|
||||||
|
const entry = decodedByCategory.get(category.id);
|
||||||
|
const policy = entry
|
||||||
|
? decoratePolicy(entry.decoded, runs, entry.isDefault)
|
||||||
|
: null;
|
||||||
|
return { category, config: POLICY_CONFIG[category.id], policy };
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = wirePolicies.filter((p) => p.enabled).length;
|
||||||
|
const paused = wirePolicies.filter((p) => !p.enabled).length;
|
||||||
|
const enabledPolicyIds = new Set(
|
||||||
|
wirePolicies.filter((p) => p.enabled).map((p) => p.id),
|
||||||
|
);
|
||||||
|
const docsEnforced = runs.filter(
|
||||||
|
(r) =>
|
||||||
|
r.status === "COMPLETED" &&
|
||||||
|
r.policyId != null &&
|
||||||
|
enabledPolicyIds.has(r.policyId),
|
||||||
|
).length;
|
||||||
|
const summary: PoliciesSummary = {
|
||||||
|
active,
|
||||||
|
paused,
|
||||||
|
categories: POLICY_CATEGORIES.length,
|
||||||
|
docsEnforced,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { summary, catalogue };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
||||||
export async function fetchPolicy(id: string): Promise<Policy> {
|
export async function fetchPolicy(id: string): Promise<WirePolicy> {
|
||||||
return apiClient.local.json<Policy>(
|
return apiClient.local.json<WirePolicy>(
|
||||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/policies — create (blank id) or update (matched id). The backend
|
* POST /api/v1/policies — create (blank id) or update (matched id). The
|
||||||
* assigns owner + team server-side and returns the stored policy with its id.
|
* backend stamps owner + teamId server-side and returns the stored record.
|
||||||
*/
|
*/
|
||||||
export async function savePolicy(policy: Policy): Promise<Policy> {
|
export async function savePolicy(wire: WirePolicy): Promise<WirePolicy> {
|
||||||
return apiClient.local.json<Policy>("/api/v1/policies", {
|
return apiClient.local.json<WirePolicy>("/api/v1/policies", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: policy,
|
body: wire,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
|
/** DELETE /api/v1/policies/{id} */
|
||||||
export async function deletePolicy(id: string): Promise<void> {
|
export async function deletePolicy(id: string): Promise<void> {
|
||||||
await apiClient.local.json<void>(
|
await apiClient.local.json<void>(
|
||||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||||
@@ -76,22 +177,80 @@ export async function deletePolicy(id: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The async run acknowledgement: a run id to poll for status. */
|
// ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ────────
|
||||||
export interface PolicyRunResponse {
|
|
||||||
status: boolean;
|
const DEFAULT_RETRIES = 3;
|
||||||
/** The run id (poll GET /api/v1/policies/run/{id} for status). */
|
const DEFAULT_RETRY_DELAY = 5;
|
||||||
fileId: string | null;
|
|
||||||
message: string | null;
|
// Catalogue policy bodies carry categoryId at the top level so the pipelines
|
||||||
|
// mock handler can discriminate them from raw pipeline saves on the shared
|
||||||
|
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
|
||||||
|
type CatalogueWireBody = WirePolicy & { categoryId: string };
|
||||||
|
|
||||||
|
/** Build a wire policy from a setup wizard result. */
|
||||||
|
export function buildWireFromSetup(
|
||||||
|
entry: CatalogueEntry,
|
||||||
|
result: PolicySetupResult,
|
||||||
|
enabled = true,
|
||||||
|
): CatalogueWireBody {
|
||||||
|
return {
|
||||||
|
categoryId: entry.category.id,
|
||||||
|
...toWirePolicy({
|
||||||
|
id: entry.policy?.state.backendId ?? "",
|
||||||
|
name: `${entry.category.label} Policy`,
|
||||||
|
enabled,
|
||||||
|
categoryId: entry.category.id,
|
||||||
|
sources: result.sources,
|
||||||
|
scopeTypes: result.scopeTypes,
|
||||||
|
reviewerEmail: result.reviewerEmail,
|
||||||
|
fieldValues: result.fieldValues,
|
||||||
|
runOn: result.runOn,
|
||||||
|
outputMode: result.outputMode,
|
||||||
|
outputName: result.outputName,
|
||||||
|
outputNamePosition: result.outputNamePosition,
|
||||||
|
maxRetries: result.maxRetries,
|
||||||
|
retryDelayMinutes: result.retryDelayMinutes,
|
||||||
|
steps: result.steps,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a wire policy from an existing decorated policy (e.g. for pause/resume). */
|
||||||
|
export function buildWireFromState(
|
||||||
|
entry: CatalogueEntry,
|
||||||
|
policy: DecoratedPolicy,
|
||||||
|
enabled: boolean,
|
||||||
|
): CatalogueWireBody {
|
||||||
|
const s = policy.state;
|
||||||
|
return {
|
||||||
|
categoryId: entry.category.id,
|
||||||
|
...toWirePolicy({
|
||||||
|
id: s.backendId ?? "",
|
||||||
|
name: `${entry.category.label} Policy`,
|
||||||
|
enabled,
|
||||||
|
categoryId: entry.category.id,
|
||||||
|
sources: s.sources,
|
||||||
|
scopeTypes: s.scopeTypes,
|
||||||
|
reviewerEmail: s.reviewerEmail,
|
||||||
|
fieldValues: s.fieldValues,
|
||||||
|
runOn: s.runOn ?? "upload",
|
||||||
|
outputMode: s.outputMode ?? "new_version",
|
||||||
|
outputName: s.outputName ?? "",
|
||||||
|
outputNamePosition: s.outputNamePosition ?? "suffix",
|
||||||
|
maxRetries: s.maxRetries ?? DEFAULT_RETRIES,
|
||||||
|
retryDelayMinutes: s.retryDelayMinutes ?? DEFAULT_RETRY_DELAY,
|
||||||
|
steps: policy.steps,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/policies/{id}/run — run a stored policy now. The real endpoint
|
* POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The
|
||||||
* is multipart (the documents to process); the portal has no files to attach,
|
* real endpoint is multipart; the portal sends no files, relying on whatever
|
||||||
* so this triggers the policy on whatever the backend has queued and returns a
|
* the backend has queued for this policy.
|
||||||
* run id. Runs regardless of the policy's enabled flag.
|
|
||||||
*/
|
*/
|
||||||
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
export async function runPolicy(id: string): Promise<{ runId: string }> {
|
||||||
return apiClient.local.json<PolicyRunResponse>(
|
return apiClient.local.json<{ runId: string }>(
|
||||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, Chip, StatusBadge, StatTile } from "@shared/components";
|
import { Card, Chip, StatusBadge } from "@shared/components";
|
||||||
import type { CatalogueEntry } from "@portal/api/policies";
|
import type { CatalogueEntry } from "@portal/api/policies";
|
||||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||||
import "@portal/views/Policies.css";
|
import "@portal/views/Policies.css";
|
||||||
@@ -9,17 +9,13 @@ interface PolicyCategoryCardProps {
|
|||||||
onOpen: (entry: CatalogueEntry) => void;
|
onOpen: (entry: CatalogueEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One card per policy category. Configured categories show the live status +
|
|
||||||
* stats and open the detail panel; unconfigured ones show the summary + a
|
|
||||||
* "Set up" affordance; coming-soon categories render locked and inert.
|
|
||||||
*/
|
|
||||||
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { category, config, policy } = entry;
|
const { category, config, policy } = entry;
|
||||||
const comingSoon = category.comingSoon === true;
|
const comingSoon = category.comingSoon === true;
|
||||||
const openable = !comingSoon;
|
const openable = !comingSoon;
|
||||||
const status = policy?.state.status;
|
const status = policy?.state.status;
|
||||||
|
const enforces = config.rules.join(" · ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -42,22 +38,39 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<header className="portal-policies__card-head">
|
<span className="portal-policies__cat-icon" aria-hidden>
|
||||||
<span
|
{policyIcon(category.icon)}
|
||||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
</span>
|
||||||
aria-hidden
|
|
||||||
>
|
<div className="portal-policies__card-identity">
|
||||||
{policyIcon(category.icon)}
|
<h2 className="portal-policies__card-title">{category.label}</h2>
|
||||||
</span>
|
{enforces && (
|
||||||
<div className="portal-policies__card-titles">
|
<span className="portal-policies__card-enforces">{enforces}</span>
|
||||||
<h2 className="portal-policies__card-title">{category.label}</h2>
|
)}
|
||||||
<span className="portal-policies__card-blurb">{category.desc}</span>
|
</div>
|
||||||
</div>
|
|
||||||
{comingSoon ? (
|
{comingSoon ? (
|
||||||
<Chip tone="neutral" size="sm">
|
<Chip tone="neutral" size="sm">
|
||||||
{t("policies.card.comingSoon")}
|
{t("policies.card.comingSoon")}
|
||||||
</Chip>
|
</Chip>
|
||||||
) : policy ? (
|
) : policy ? (
|
||||||
|
<div className="portal-policies__card-meta">
|
||||||
|
<span className="portal-policies__card-statpair">
|
||||||
|
<span className="portal-policies__card-statval">
|
||||||
|
{policy.stats.enforced.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="portal-policies__card-statlbl">
|
||||||
|
{t("policies.stats.docsEnforced")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="portal-policies__card-statpair">
|
||||||
|
<span className="portal-policies__card-statval">
|
||||||
|
{policy.stats.dataProcessed}
|
||||||
|
</span>
|
||||||
|
<span className="portal-policies__card-statlbl">
|
||||||
|
{t("policies.stats.dataProcessed")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
tone={status === "paused" ? "warning" : "success"}
|
tone={status === "paused" ? "warning" : "success"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -67,45 +80,11 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
|||||||
? t("policies.status.paused")
|
? t("policies.status.paused")
|
||||||
: t("policies.status.active")}
|
: t("policies.status.active")}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
) : (
|
</div>
|
||||||
<Chip tone="blue" size="sm">
|
|
||||||
{t("policies.card.notSetUp")}
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p className="portal-policies__card-summary">{config.summary}</p>
|
|
||||||
|
|
||||||
{policy ? (
|
|
||||||
<footer className="portal-policies__card-stats">
|
|
||||||
<StatTile
|
|
||||||
label={t("policies.stats.docsEnforced")}
|
|
||||||
value={policy.stats.enforced.toLocaleString()}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label={t("policies.stats.dataProcessed")}
|
|
||||||
value={policy.stats.dataProcessed}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label={t("policies.stats.activeFor")}
|
|
||||||
value={policy.stats.activeFor}
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
) : (
|
) : (
|
||||||
<footer className="portal-policies__card-foot">
|
<Chip tone="blue" size="sm">
|
||||||
<div className="portal-policies__card-rules">
|
{t("policies.card.notSetUp")}
|
||||||
{config.rules.slice(0, 3).map((rule) => (
|
</Chip>
|
||||||
<Chip key={rule} tone="neutral" size="sm">
|
|
||||||
{rule}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!comingSoon && (
|
|
||||||
<span className="portal-policies__card-cta">
|
|
||||||
{t("policies.card.setUp")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const meta: Meta<typeof PolicyDetailPanel> = {
|
|||||||
onRun: () => {},
|
onRun: () => {},
|
||||||
onTogglePause: () => {},
|
onTogglePause: () => {},
|
||||||
onDelete: () => {},
|
onDelete: () => {},
|
||||||
|
onRetry: () => {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -43,3 +44,40 @@ export const CustomNoActivity: Story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Flagged activity items — shows retry button and error expansion. */
|
||||||
|
export const WithFlaggedItems: Story = {
|
||||||
|
args: {
|
||||||
|
policy: {
|
||||||
|
...decorateForStory("security"),
|
||||||
|
state: { ...decorateForStory("security").state, isDefault: false },
|
||||||
|
activity: [
|
||||||
|
{
|
||||||
|
doc: "Q4-Report.pdf",
|
||||||
|
action: "Low-confidence match — routed for review",
|
||||||
|
time: "2h ago",
|
||||||
|
status: "flagged",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
doc: "Contract-2026.pdf",
|
||||||
|
action:
|
||||||
|
"Enforcement failed: timeout after 30s — step 2/3 (redact) did not complete within the allowed window. Check the document for unusual formatting or large embedded images.",
|
||||||
|
time: "4h ago",
|
||||||
|
status: "flagged",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
doc: "Invoice-March.pdf",
|
||||||
|
action: "Enforced successfully",
|
||||||
|
time: "6h ago",
|
||||||
|
status: "enforced",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
doc: "HR-Policy-v3.pdf",
|
||||||
|
action: "Processing…",
|
||||||
|
time: "just now",
|
||||||
|
status: "processing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,42 +1,102 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Banner,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Chip,
|
|
||||||
EmptyState,
|
EmptyState,
|
||||||
Modal,
|
Modal,
|
||||||
StatTile,
|
StatTile,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
} from "@shared/components";
|
} from "@shared/components";
|
||||||
import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies";
|
import {
|
||||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
humanizeEndpoint,
|
||||||
|
type DecoratedPolicy,
|
||||||
|
type PolicyActivityItem,
|
||||||
|
} from "@portal/api/policies";
|
||||||
import "@portal/views/Policies.css";
|
import "@portal/views/Policies.css";
|
||||||
|
|
||||||
interface PolicyDetailPanelProps {
|
interface PolicyDetailPanelProps {
|
||||||
/** The configured policy being viewed, or null when closed. */
|
|
||||||
policy: DecoratedPolicy | null;
|
policy: DecoratedPolicy | null;
|
||||||
/** Whether a lifecycle action (run/pause/delete) is in flight. */
|
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onRun: () => void;
|
onRun?: () => void;
|
||||||
onTogglePause: () => void;
|
onTogglePause: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onRetry?: (item: PolicyActivityItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_TONE = {
|
function CheckIcon() {
|
||||||
enforced: "success",
|
return (
|
||||||
flagged: "warning",
|
<svg
|
||||||
processing: "info",
|
width="13"
|
||||||
} as const;
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarnIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpinIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="portal-policies__activity-spin"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityError({ message }: { message: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const needsToggle = message.length > 80 || message.includes("\n");
|
||||||
|
if (!needsToggle) return <>{message}</>;
|
||||||
|
return (
|
||||||
|
<span className="portal-policies__activity-error">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"portal-policies__activity-error-text" +
|
||||||
|
(expanded ? "" : " portal-policies__activity-error-text--clamped")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-policies__link portal-policies__activity-error-toggle"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? t("policies.detail.showLess")
|
||||||
|
: t("policies.detail.showMore")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrative view for a configured policy: the enforced tool chain, recent
|
|
||||||
* activity, summary stats, and the lifecycle actions (run now, pause/resume,
|
|
||||||
* delete). Built-in (default) policies hide Delete — they're configurable but
|
|
||||||
* not deletable, matching the backend.
|
|
||||||
*/
|
|
||||||
export function PolicyDetailPanel({
|
export function PolicyDetailPanel({
|
||||||
policy,
|
policy,
|
||||||
busy = false,
|
busy = false,
|
||||||
@@ -45,31 +105,36 @@ export function PolicyDetailPanel({
|
|||||||
onRun,
|
onRun,
|
||||||
onTogglePause,
|
onTogglePause,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRetry,
|
||||||
}: PolicyDetailPanelProps) {
|
}: PolicyDetailPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!policy) return null;
|
if (!policy) return null;
|
||||||
const { category, config, state, steps, stats, activity } = policy;
|
const { category, config, state, steps, stats, activity } = policy;
|
||||||
const isPaused = state.status === "paused";
|
const isPaused = state.status === "paused";
|
||||||
const canDelete = state.isDefault !== true;
|
const canDelete = state.isDefault !== true;
|
||||||
const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : [];
|
|
||||||
|
const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : null;
|
||||||
|
const hasEditorSource = state.sources.includes("editor");
|
||||||
|
const trigger =
|
||||||
|
state.runOn === "export"
|
||||||
|
? t("policies.detail.onEveryExport")
|
||||||
|
: t("policies.detail.onEveryUpload");
|
||||||
|
const outputLabel =
|
||||||
|
state.outputMode === "new_file"
|
||||||
|
? t("policies.detail.outputAsNewFile")
|
||||||
|
: t("policies.detail.outputAsNewVersion");
|
||||||
|
|
||||||
|
function sourceLabel(id: string) {
|
||||||
|
if (id === "editor") return t("sources.types.editor.label");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width="lg"
|
width="lg"
|
||||||
title={
|
title={category.label}
|
||||||
<span className="portal-policies__wizard-title">
|
|
||||||
<span
|
|
||||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{policyIcon(category.icon)}
|
|
||||||
</span>
|
|
||||||
{t("policies.detail.title", { category: category.label })}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
subtitle={config.summary}
|
|
||||||
footer={
|
footer={
|
||||||
<div className="portal-policies__detail-foot">
|
<div className="portal-policies__detail-foot">
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
@@ -84,15 +149,17 @@ export function PolicyDetailPanel({
|
|||||||
{t("policies.detail.actions.delete")}
|
{t("policies.detail.actions.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{onRun && (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={onRun}
|
size="sm"
|
||||||
disabled={busy}
|
onClick={onRun}
|
||||||
style={canDelete ? undefined : { marginRight: "auto" }}
|
disabled={busy}
|
||||||
>
|
style={canDelete ? undefined : { marginRight: "auto" }}
|
||||||
{t("policies.detail.actions.runNow")}
|
>
|
||||||
</Button>
|
{t("policies.detail.actions.runNow")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -109,57 +176,66 @@ export function PolicyDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* Status + trigger strip */}
|
||||||
<div className="portal-policies__detail-status">
|
<div className="portal-policies__detail-status">
|
||||||
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
|
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
|
||||||
{isPaused ? t("policies.status.paused") : t("policies.status.active")}
|
{isPaused ? t("policies.status.paused") : t("policies.status.active")}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span className="portal-policies__detail-meta">
|
{hasEditorSource && (
|
||||||
{t("policies.detail.meta", {
|
<>
|
||||||
event: state.runOn ?? "upload",
|
<span className="portal-policies__detail-sep" aria-hidden>
|
||||||
output:
|
·
|
||||||
state.outputMode === "new_file"
|
</span>
|
||||||
? t("policies.detail.outputAsNewFile")
|
<span className="portal-policies__detail-meta">{trigger}</span>
|
||||||
: t("policies.detail.outputAsNewVersion"),
|
<span className="portal-policies__detail-sep" aria-hidden>
|
||||||
})}
|
·
|
||||||
|
</span>
|
||||||
|
<span className="portal-policies__detail-meta">{outputLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enforces — plain text, no pills */}
|
||||||
|
<div className="portal-policies__detail-inline">
|
||||||
|
<span className="portal-policies__detail-inline-label">
|
||||||
|
{t("policies.detail.enforces")}
|
||||||
|
</span>
|
||||||
|
<span className="portal-policies__detail-inline-value">
|
||||||
|
{enforceItems
|
||||||
|
? enforceItems.map((op, i) => (
|
||||||
|
<span key={op}>
|
||||||
|
{i > 0 && (
|
||||||
|
<span
|
||||||
|
className="portal-policies__enforce-arrow"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
→{" "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{humanizeEndpoint(op)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: config.rules.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="portal-policies__wizard-heading">
|
{/* Sources */}
|
||||||
{t("policies.detail.enforces")}
|
{state.sources.length > 0 && (
|
||||||
</h3>
|
<div className="portal-policies__detail-inline">
|
||||||
<Card padding="default">
|
<span className="portal-policies__detail-inline-label">
|
||||||
{enforceItems.length > 0 ? (
|
{t("policies.detail.sources")}
|
||||||
<div className="portal-policies__enforce-flow">
|
</span>
|
||||||
{enforceItems.map((op, i) => (
|
<span className="portal-policies__detail-inline-value">
|
||||||
<span key={op} className="portal-policies__enforce-item">
|
{state.sources.map(sourceLabel).join(" · ")}
|
||||||
{i > 0 && (
|
</span>
|
||||||
<span className="portal-policies__enforce-arrow" aria-hidden>
|
</div>
|
||||||
→
|
)}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Chip tone="blue" size="sm">
|
|
||||||
{humanizeEndpoint(op)}
|
|
||||||
</Chip>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="portal-policies__enforce-flow">
|
|
||||||
{config.rules.map((rule) => (
|
|
||||||
<Chip key={rule} tone="neutral" size="sm">
|
|
||||||
{rule}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="portal-policies__enforce-note">
|
|
||||||
{t("policies.detail.enforceNote", { scope: config.scopeLabel })}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<h3 className="portal-policies__wizard-heading">
|
<h3 className="portal-policies__wizard-heading">
|
||||||
{t("policies.detail.recentActivity")}
|
{t("policies.detail.recentActivity")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{activity.length > 0 ? (
|
{activity.length > 0 ? (
|
||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
{activity.map((item, i) => (
|
{activity.map((item, i) => (
|
||||||
@@ -168,20 +244,46 @@ export function PolicyDetailPanel({
|
|||||||
className="portal-policies__activity-row"
|
className="portal-policies__activity-row"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`portal-policies__activity-dot portal-policies__activity-dot--${ACTIVITY_TONE[item.status]}`}
|
className={`portal-policies__activity-icon portal-policies__activity-icon--${
|
||||||
aria-hidden
|
item.status === "flagged"
|
||||||
/>
|
? "warning"
|
||||||
|
: item.status === "processing"
|
||||||
|
? "info"
|
||||||
|
: "success"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.status === "flagged" ? (
|
||||||
|
<WarnIcon />
|
||||||
|
) : item.status === "processing" ? (
|
||||||
|
<SpinIcon />
|
||||||
|
) : (
|
||||||
|
<CheckIcon />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="portal-policies__activity-text">
|
<span className="portal-policies__activity-text">
|
||||||
<span className="portal-policies__activity-doc">
|
<span className="portal-policies__activity-doc">
|
||||||
{item.doc}
|
{item.doc}
|
||||||
</span>
|
</span>
|
||||||
<span className="portal-policies__activity-action">
|
<span className="portal-policies__activity-action">
|
||||||
{item.action}
|
{item.status === "flagged" ? (
|
||||||
|
<ActivityError message={item.action} />
|
||||||
|
) : (
|
||||||
|
item.action
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="portal-policies__activity-time">
|
<span className="portal-policies__activity-time">
|
||||||
{item.time}
|
{item.time}
|
||||||
</span>
|
</span>
|
||||||
|
{item.status === "flagged" && onRetry && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="portal-policies__link portal-policies__activity-retry"
|
||||||
|
onClick={() => onRetry(item)}
|
||||||
|
>
|
||||||
|
{t("policies.detail.retry")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -209,16 +311,6 @@ export function PolicyDetailPanel({
|
|||||||
value={stats.activeFor}
|
value={stats.activeFor}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{state.scopeTypes.length > 0 && (
|
|
||||||
<Banner
|
|
||||||
tone="neutral"
|
|
||||||
title={t("policies.detail.scoped.title")}
|
|
||||||
description={t("policies.detail.scoped.description", {
|
|
||||||
types: state.scopeTypes.join(", "),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ import {
|
|||||||
} from "@shared/components";
|
} from "@shared/components";
|
||||||
import {
|
import {
|
||||||
POLICY_DOC_TYPES,
|
POLICY_DOC_TYPES,
|
||||||
POLICY_SOURCES,
|
|
||||||
humanizeEndpoint,
|
humanizeEndpoint,
|
||||||
type CatalogueEntry,
|
type CatalogueEntry,
|
||||||
type PipelineStep,
|
type PipelineStep,
|
||||||
type PolicySetupResult,
|
type PolicySetupResult,
|
||||||
} from "@portal/api/policies";
|
} from "@portal/api/policies";
|
||||||
|
import { fetchSources } from "@portal/api/sources";
|
||||||
|
import { useAsync } from "@portal/hooks/useAsync";
|
||||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||||
|
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||||
import "@portal/views/Policies.css";
|
import "@portal/views/Policies.css";
|
||||||
|
|
||||||
interface PolicySetupWizardProps {
|
interface PolicySetupWizardProps {
|
||||||
@@ -59,15 +61,27 @@ function resolveFieldValues(
|
|||||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||||
* starts enabled — the user toggles tools off in the workflow.
|
* starts enabled — the user toggles tools off in the workflow.
|
||||||
*/
|
*/
|
||||||
|
// Temporary: tracks which tools start disabled until the tool registry lands in
|
||||||
|
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||||
|
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||||
|
|
||||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||||
const source = entry.policy?.steps?.length
|
const savedSteps = entry.policy?.steps ?? [];
|
||||||
? entry.policy.steps
|
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||||
: entry.config.defaultOperations;
|
// Always use defaultOperations as the canonical list so tools added after a
|
||||||
return source.map((s) => ({
|
// policy was first saved still appear when editing.
|
||||||
operation: s.operation,
|
return entry.config.defaultOperations.map((s) => {
|
||||||
enabled: true,
|
const saved = savedByOp.get(s.operation);
|
||||||
parameters: s.parameters,
|
return {
|
||||||
}));
|
operation: s.operation,
|
||||||
|
enabled: saved
|
||||||
|
? true
|
||||||
|
: savedSteps.length > 0
|
||||||
|
? false
|
||||||
|
: !DISABLED_BY_DEFAULT.has(s.operation),
|
||||||
|
parameters: saved?.parameters ?? s.parameters,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,27 +126,50 @@ function PolicySetupWizardBody({
|
|||||||
resolveFieldValues(entry),
|
resolveFieldValues(entry),
|
||||||
);
|
);
|
||||||
const [sources, setSources] = useState<string[]>(
|
const [sources, setSources] = useState<string[]>(
|
||||||
policy?.state.sources.length ? policy.state.sources : ["editor"],
|
policy?.state.sources ?? ["editor"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||||
|
const availableSources = useMemo(() => {
|
||||||
|
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||||
|
(s) => s.status !== "disabled",
|
||||||
|
);
|
||||||
|
const editorSource = {
|
||||||
|
id: "editor",
|
||||||
|
name: t("sources.types.editor.label"),
|
||||||
|
type: "editor",
|
||||||
|
status: "active" as const,
|
||||||
|
referenceCount: 0,
|
||||||
|
referencingPolicies: [],
|
||||||
|
config: [],
|
||||||
|
docsTotal: null,
|
||||||
|
};
|
||||||
|
return [editorSource, ...backendSources];
|
||||||
|
}, [sourcesAsync.data, t]);
|
||||||
const [scopeNarrow, setScopeNarrow] = useState(
|
const [scopeNarrow, setScopeNarrow] = useState(
|
||||||
(policy?.state.scopeTypes.length ?? 0) > 0,
|
(policy?.state.scopeTypes.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
const [scopeTypes, setScopeTypes] = useState<string[]>(
|
const [scopeTypes, setScopeTypes] = useState<string[]>(
|
||||||
policy?.state.scopeTypes ?? [],
|
policy?.state.scopeTypes ?? [],
|
||||||
);
|
);
|
||||||
const [reviewerEmail, setReviewerEmail] = useState(
|
// TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]).
|
||||||
policy?.state.reviewerEmail ?? "you@acme.com",
|
// Store username (which is the email in Spring Security) as reviewerEmail.
|
||||||
);
|
// See UserSelector.tsx in the editor for the grouping/display pattern.
|
||||||
|
const [reviewerEmail] = useState(policy?.state.reviewerEmail ?? "");
|
||||||
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
||||||
policy?.state.outputMode ?? "new_version",
|
policy?.state.outputMode ?? "new_version",
|
||||||
);
|
);
|
||||||
const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
|
const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
|
||||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||||
"prefix" | "suffix" | "auto-number"
|
"prefix" | "suffix" | "auto-number"
|
||||||
>("suffix");
|
>(policy?.state.outputNamePosition ?? "suffix");
|
||||||
const [runOn, setRunOn] = useState<"upload" | "export">(
|
const [runOn, setRunOn] = useState<"upload" | "export">(
|
||||||
policy?.state.runOn ?? "upload",
|
policy?.state.runOn ?? "upload",
|
||||||
);
|
);
|
||||||
|
const [maxRetries, setMaxRetries] = useState(policy?.state.maxRetries ?? 3);
|
||||||
|
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
||||||
|
policy?.state.retryDelayMinutes ?? 5,
|
||||||
|
);
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -180,6 +217,8 @@ function PolicySetupWizardBody({
|
|||||||
outputName: outputName.trim(),
|
outputName: outputName.trim(),
|
||||||
outputNamePosition,
|
outputNamePosition,
|
||||||
runOn,
|
runOn,
|
||||||
|
maxRetries,
|
||||||
|
retryDelayMinutes,
|
||||||
steps,
|
steps,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -197,10 +236,7 @@ function PolicySetupWizardBody({
|
|||||||
width="lg"
|
width="lg"
|
||||||
title={
|
title={
|
||||||
<span className="portal-policies__wizard-title">
|
<span className="portal-policies__wizard-title">
|
||||||
<span
|
<span className="portal-policies__cat-icon" aria-hidden>
|
||||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{policyIcon(category.icon)}
|
{policyIcon(category.icon)}
|
||||||
</span>
|
</span>
|
||||||
{isEdit
|
{isEdit
|
||||||
@@ -272,9 +308,7 @@ function PolicySetupWizardBody({
|
|||||||
<span className="portal-policies__tool-name">
|
<span className="portal-policies__tool-name">
|
||||||
{humanizeEndpoint(tl.operation)}
|
{humanizeEndpoint(tl.operation)}
|
||||||
</span>
|
</span>
|
||||||
<code className="portal-policies__tool-endpoint">
|
<span style={{ flex: 1 }} />
|
||||||
{tl.operation}
|
|
||||||
</code>
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
size="sm"
|
size="sm"
|
||||||
checked={tl.enabled}
|
checked={tl.enabled}
|
||||||
@@ -315,31 +349,43 @@ function PolicySetupWizardBody({
|
|||||||
{t("policies.wizard.sources.heading")}
|
{t("policies.wizard.sources.heading")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="portal-policies__sources">
|
<div className="portal-policies__sources">
|
||||||
{POLICY_SOURCES.map((src) => (
|
{sourcesAsync.loading && !sourcesAsync.data ? (
|
||||||
<button
|
<p className="portal-policies__sources-loading">
|
||||||
key={src.id}
|
{t("policies.wizard.sources.loading")}
|
||||||
type="button"
|
</p>
|
||||||
className={
|
) : availableSources.length === 1 ? (
|
||||||
"portal-policies__source" +
|
<Banner
|
||||||
(sources.includes(src.id)
|
tone="neutral"
|
||||||
? " portal-policies__source--on"
|
title={t("policies.wizard.sources.emptyTitle")}
|
||||||
: "")
|
description={t("policies.wizard.sources.emptyDescription")}
|
||||||
}
|
/>
|
||||||
onClick={() => toggleSource(src.id)}
|
) : (
|
||||||
>
|
availableSources.map((src) => (
|
||||||
<span className="portal-policies__source-icon" aria-hidden>
|
<button
|
||||||
{policyIcon(src.icon)}
|
key={src.id}
|
||||||
</span>
|
type="button"
|
||||||
<span className="portal-policies__source-text">
|
className={
|
||||||
<span className="portal-policies__source-label">
|
"portal-policies__source" +
|
||||||
{src.label}
|
(sources.includes(src.id)
|
||||||
|
? " portal-policies__source--on"
|
||||||
|
: "")
|
||||||
|
}
|
||||||
|
onClick={() => toggleSource(src.id)}
|
||||||
|
>
|
||||||
|
<span className="portal-policies__source-icon" aria-hidden>
|
||||||
|
{sourceTypeMeta(src.type).icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="portal-policies__source-desc">
|
<span className="portal-policies__source-text">
|
||||||
{src.desc}
|
<span className="portal-policies__source-label">
|
||||||
|
{src.name}
|
||||||
|
</span>
|
||||||
|
<span className="portal-policies__source-desc">
|
||||||
|
{src.type}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
))
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="portal-policies__wizard-heading">
|
<h3 className="portal-policies__wizard-heading">
|
||||||
@@ -392,107 +438,130 @@ function PolicySetupWizardBody({
|
|||||||
{t("policies.wizard.output.heading")}
|
{t("policies.wizard.output.heading")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="portal-policies__fields">
|
<div className="portal-policies__fields">
|
||||||
<FormField
|
{sources.includes("editor") && (
|
||||||
label={t("policies.wizard.output.runOn.label")}
|
<>
|
||||||
helperText={t("policies.wizard.output.runOn.helper")}
|
<FormField
|
||||||
>
|
label={t("policies.wizard.output.runOn.label")}
|
||||||
<Select
|
helperText={t("policies.wizard.output.runOn.helper")}
|
||||||
inputSize="sm"
|
>
|
||||||
value={runOn}
|
<Select
|
||||||
onChange={(e) =>
|
|
||||||
setRunOn(e.target.value as "upload" | "export")
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "upload",
|
|
||||||
label: t("policies.wizard.output.runOn.upload"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "export",
|
|
||||||
label: t("policies.wizard.output.runOn.export"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t("policies.wizard.output.outputAs.label")}>
|
|
||||||
<Select
|
|
||||||
inputSize="sm"
|
|
||||||
value={outputMode}
|
|
||||||
onChange={(e) => {
|
|
||||||
const mode = e.target.value as "new_file" | "new_version";
|
|
||||||
setOutputMode(mode);
|
|
||||||
// Auto-number only applies to separate new files.
|
|
||||||
if (
|
|
||||||
mode === "new_version" &&
|
|
||||||
outputNamePosition === "auto-number"
|
|
||||||
) {
|
|
||||||
setOutputNamePosition("suffix");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "new_version",
|
|
||||||
label: t("policies.wizard.output.outputAs.newVersion"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "new_file",
|
|
||||||
label: t("policies.wizard.output.outputAs.newFile"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t("policies.wizard.output.filenameRule.label")}>
|
|
||||||
<div className="portal-policies__name-row">
|
|
||||||
<Select
|
|
||||||
inputSize="sm"
|
|
||||||
value={outputNamePosition}
|
|
||||||
onChange={(e) =>
|
|
||||||
setOutputNamePosition(
|
|
||||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "prefix",
|
|
||||||
label: t("policies.wizard.output.filenameRule.prefix"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "suffix",
|
|
||||||
label: t("policies.wizard.output.filenameRule.suffix"),
|
|
||||||
},
|
|
||||||
...(outputMode === "new_file"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
value: "auto-number",
|
|
||||||
label: t(
|
|
||||||
"policies.wizard.output.filenameRule.autoNumber",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{outputNamePosition !== "auto-number" && (
|
|
||||||
<Input
|
|
||||||
inputSize="sm"
|
inputSize="sm"
|
||||||
value={outputName}
|
value={runOn}
|
||||||
placeholder={t(
|
onChange={(e) =>
|
||||||
"policies.wizard.output.filenameRule.placeholder",
|
setRunOn(e.target.value as "upload" | "export")
|
||||||
)}
|
}
|
||||||
onChange={(e) => setOutputName(e.target.value)}
|
options={[
|
||||||
|
{
|
||||||
|
value: "upload",
|
||||||
|
label: t("policies.wizard.output.runOn.upload"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "export",
|
||||||
|
label: t("policies.wizard.output.runOn.export"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
</FormField>
|
||||||
</div>
|
<FormField label={t("policies.wizard.output.outputAs.label")}>
|
||||||
</FormField>
|
<Select
|
||||||
<FormField
|
inputSize="sm"
|
||||||
label={t("policies.wizard.output.reviewerEmail.label")}
|
value={outputMode}
|
||||||
helperText={t("policies.wizard.output.reviewerEmail.helper")}
|
onChange={(e) => {
|
||||||
>
|
const mode = e.target.value as "new_file" | "new_version";
|
||||||
|
setOutputMode(mode);
|
||||||
|
// Auto-number only applies to separate new files.
|
||||||
|
if (
|
||||||
|
mode === "new_version" &&
|
||||||
|
outputNamePosition === "auto-number"
|
||||||
|
) {
|
||||||
|
setOutputNamePosition("suffix");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "new_version",
|
||||||
|
label: t("policies.wizard.output.outputAs.newVersion"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "new_file",
|
||||||
|
label: t("policies.wizard.output.outputAs.newFile"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label={t("policies.wizard.output.filenameRule.label")}
|
||||||
|
>
|
||||||
|
<div className="portal-policies__name-row">
|
||||||
|
<Select
|
||||||
|
inputSize="sm"
|
||||||
|
value={outputNamePosition}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOutputNamePosition(
|
||||||
|
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "prefix",
|
||||||
|
label: t(
|
||||||
|
"policies.wizard.output.filenameRule.prefix",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "suffix",
|
||||||
|
label: t(
|
||||||
|
"policies.wizard.output.filenameRule.suffix",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(outputMode === "new_file"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: "auto-number",
|
||||||
|
label: t(
|
||||||
|
"policies.wizard.output.filenameRule.autoNumber",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{outputNamePosition !== "auto-number" && (
|
||||||
|
<Input
|
||||||
|
inputSize="sm"
|
||||||
|
value={outputName}
|
||||||
|
placeholder={t(
|
||||||
|
"policies.wizard.output.filenameRule.placeholder",
|
||||||
|
)}
|
||||||
|
onChange={(e) => setOutputName(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* TODO: reviewer user-picker goes here */}
|
||||||
|
<h4 className="portal-policies__wizard-subheading">
|
||||||
|
{t("policies.wizard.output.retries.heading")}
|
||||||
|
</h4>
|
||||||
|
<FormField label={t("policies.wizard.output.retries.maxLabel")}>
|
||||||
<Input
|
<Input
|
||||||
inputSize="sm"
|
inputSize="sm"
|
||||||
type="email"
|
type="number"
|
||||||
value={reviewerEmail}
|
value={String(maxRetries)}
|
||||||
onChange={(e) => setReviewerEmail(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={t("policies.wizard.output.retries.delayLabel")}>
|
||||||
|
<Input
|
||||||
|
inputSize="sm"
|
||||||
|
type="number"
|
||||||
|
value={String(retryDelayMinutes)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,38 +3,53 @@
|
|||||||
* policy built straight from the catalogue + seed data, so stories render the
|
* policy built straight from the catalogue + seed data, so stories render the
|
||||||
* same shapes the MSW handlers serve without standing up the whole API.
|
* same shapes the MSW handlers serve without standing up the whole API.
|
||||||
*/
|
*/
|
||||||
|
import { fromWirePolicy } from "@shared/policies/codec";
|
||||||
|
import { runsToActivity, runsToStats } from "@shared/policies/runs";
|
||||||
import {
|
import {
|
||||||
POLICY_CATEGORIES,
|
POLICY_CATEGORIES,
|
||||||
POLICY_CONFIG,
|
POLICY_CONFIG,
|
||||||
seedRuntime,
|
seedPolicies,
|
||||||
|
seedPolicyRuns,
|
||||||
type DecoratedPolicy,
|
type DecoratedPolicy,
|
||||||
|
type PolicyState,
|
||||||
} from "@portal/mocks/policies";
|
} from "@portal/mocks/policies";
|
||||||
|
|
||||||
export { POLICY_CATEGORIES, POLICY_CONFIG };
|
export { POLICY_CATEGORIES, POLICY_CONFIG };
|
||||||
|
|
||||||
/** A decorated, active policy for a category, mirroring the handler's decorate(). */
|
/** A decorated, active policy for a category, mirroring fetchPolicies() assembly. */
|
||||||
export function decorateForStory(categoryId: string): DecoratedPolicy {
|
export function decorateForStory(categoryId: string): DecoratedPolicy {
|
||||||
const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!;
|
const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!;
|
||||||
const config = POLICY_CONFIG[categoryId];
|
const config = POLICY_CONFIG[categoryId];
|
||||||
const rt = seedRuntime().pol_security_default;
|
|
||||||
|
// Use the seeded security policy for any category (story only needs the shape).
|
||||||
|
const wire = seedPolicies()[0];
|
||||||
|
const decoded = fromWirePolicy(wire);
|
||||||
|
const allRuns = seedPolicyRuns();
|
||||||
|
const policyRuns = allRuns.filter((r) => r.policyId === wire.id);
|
||||||
|
|
||||||
|
const state: PolicyState = {
|
||||||
|
configured: true,
|
||||||
|
status: decoded.enabled ? "active" : "paused",
|
||||||
|
sources: decoded.sources,
|
||||||
|
scopeTypes: decoded.scopeTypes,
|
||||||
|
reviewerEmail: decoded.reviewerEmail,
|
||||||
|
fieldValues: decoded.fieldValues,
|
||||||
|
outputMode: decoded.outputMode,
|
||||||
|
outputName: decoded.outputName,
|
||||||
|
outputNamePosition: decoded.outputNamePosition,
|
||||||
|
runOn: decoded.runOn,
|
||||||
|
maxRetries: decoded.maxRetries,
|
||||||
|
retryDelayMinutes: decoded.retryDelayMinutes,
|
||||||
|
backendId: wire.id,
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
category,
|
category,
|
||||||
config,
|
config,
|
||||||
state: {
|
state,
|
||||||
configured: true,
|
steps: decoded.steps,
|
||||||
status: "active",
|
stats: runsToStats(policyRuns),
|
||||||
sources: ["editor"],
|
activity: runsToActivity(policyRuns),
|
||||||
scopeTypes: [],
|
|
||||||
reviewerEmail: rt.reviewerEmail,
|
|
||||||
fieldValues: {},
|
|
||||||
outputMode: "new_version",
|
|
||||||
outputName: "",
|
|
||||||
runOn: "upload",
|
|
||||||
backendId: "pol_story",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
steps: config.defaultOperations,
|
|
||||||
stats: rt.stats,
|
|
||||||
activity: rt.activity,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface SourceTypeMeta {
|
|||||||
|
|
||||||
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
|
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
|
||||||
folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" },
|
folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" },
|
||||||
|
editor: { labelKey: "sources.types.editor.label", icon: "✏", tone: "green" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNKNOWN_TYPE_META: SourceTypeMeta = {
|
const UNKNOWN_TYPE_META: SourceTypeMeta = {
|
||||||
|
|||||||
@@ -1,39 +1,35 @@
|
|||||||
import { http, HttpResponse, delay } from "msw";
|
import { http, HttpResponse, delay } from "msw";
|
||||||
import {
|
import {
|
||||||
POLICY_CATEGORIES,
|
|
||||||
POLICY_CONFIG,
|
|
||||||
seedPolicies,
|
seedPolicies,
|
||||||
seedRuntime,
|
seedPolicyRuns,
|
||||||
emptyRuntime,
|
type WirePolicy,
|
||||||
type CatalogueEntry,
|
|
||||||
type DecoratedPolicy,
|
|
||||||
type PoliciesResponse,
|
|
||||||
type PoliciesSummary,
|
|
||||||
type Policy,
|
|
||||||
type PolicyRowStatus,
|
|
||||||
type PolicyRuntime,
|
|
||||||
type PolicyState,
|
|
||||||
} from "@portal/mocks/policies";
|
} from "@portal/mocks/policies";
|
||||||
|
import type { PolicyRunView } from "@shared/policies/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the
|
* The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the
|
||||||
* portal's usual `/v1/...` — so this surface is plug-and-play against the live
|
* portal's usual `/v1/...` — so this surface is plug-and-play against the live
|
||||||
* backend (drop MSW and the same calls hit Stirling). These handlers mutate an
|
* backend (drop MSW and the same calls hit Stirling).
|
||||||
* in-memory store, so create/delete/run behave like a real backend within a
|
*
|
||||||
* session (see the notifications handler for the same stateful pattern).
|
* These handlers speak the backend's actual wire contract:
|
||||||
|
* - GET /api/v1/policies → WirePolicy[]
|
||||||
|
* - GET /api/v1/policies/runs → PolicyRunView[]
|
||||||
|
* - POST /api/v1/policies → WirePolicy (create / update)
|
||||||
|
* - DELETE /api/v1/policies/:id → 204
|
||||||
|
*
|
||||||
|
* The decorated catalogue (summary, category grouping, stats) is assembled
|
||||||
|
* client-side in api/policies.ts#fetchPolicies(), mirroring the real backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Configured policies, keyed by backend id (the source of truth). */
|
let store: WirePolicy[] = seedPolicies();
|
||||||
let store: Policy[] = seedPolicies();
|
let runs: PolicyRunView[] = seedPolicyRuns();
|
||||||
/** Runtime extras the wire record doesn't carry (scope, stats, activity). */
|
|
||||||
let runtime: Record<string, PolicyRuntime> = seedRuntime();
|
|
||||||
|
|
||||||
export function resetPoliciesStore(
|
export function resetPoliciesStore(
|
||||||
seed?: Policy[],
|
seed?: WirePolicy[],
|
||||||
seedRt?: Record<string, PolicyRuntime>,
|
seedRuns?: PolicyRunView[],
|
||||||
): void {
|
): void {
|
||||||
store = seed ? [...seed] : seedPolicies();
|
store = seed ? [...seed] : seedPolicies();
|
||||||
runtime = seedRt ? { ...seedRt } : seedRuntime();
|
runs = seedRuns ? [...seedRuns] : seedPolicyRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
@@ -42,78 +38,21 @@ function nextId(categoryId: string): string {
|
|||||||
return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`;
|
return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive the display status from the wire `enabled` flag. */
|
function categoryId(wire: WirePolicy): string {
|
||||||
function rowStatus(policy: Policy): PolicyRowStatus {
|
return (wire.output?.options?.categoryId as string | undefined) ?? "";
|
||||||
return policy.enabled ? "active" : "paused";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the decorated runtime view the catalogue/detail consumes. */
|
|
||||||
function decorate(policy: Policy): DecoratedPolicy | null {
|
|
||||||
const category = POLICY_CATEGORIES.find((c) => c.id === policy.categoryId);
|
|
||||||
const config = POLICY_CONFIG[policy.categoryId];
|
|
||||||
if (!category || !config) return null;
|
|
||||||
const rt = runtime[policy.id] ?? emptyRuntime();
|
|
||||||
const status = rowStatus(policy);
|
|
||||||
const state: PolicyState = {
|
|
||||||
configured: true,
|
|
||||||
status: status === "paused" ? "paused" : "active",
|
|
||||||
sources: policy.sources.map((s) => s.source),
|
|
||||||
scopeTypes: rt.scopeTypes,
|
|
||||||
reviewerEmail: rt.reviewerEmail,
|
|
||||||
fieldValues: rt.fieldValues,
|
|
||||||
outputMode: policy.output.mode,
|
|
||||||
outputName: policy.output.name,
|
|
||||||
runOn: policy.trigger?.event ?? "upload",
|
|
||||||
backendId: policy.id,
|
|
||||||
isDefault: rt.isDefault,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
category,
|
|
||||||
config,
|
|
||||||
state,
|
|
||||||
steps: policy.steps,
|
|
||||||
stats: rt.stats,
|
|
||||||
activity: rt.activity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The full catalogue response: every category, each with its policy (or null). */
|
|
||||||
function buildResponse(): PoliciesResponse {
|
|
||||||
const byCategory = new Map<string, Policy>();
|
|
||||||
for (const p of store) byCategory.set(p.categoryId, p);
|
|
||||||
|
|
||||||
const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => {
|
|
||||||
const policy = byCategory.get(category.id);
|
|
||||||
return {
|
|
||||||
category,
|
|
||||||
config: POLICY_CONFIG[category.id],
|
|
||||||
policy: policy ? decorate(policy) : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const active = store.filter((p) => p.enabled).length;
|
|
||||||
const paused = store.filter((p) => !p.enabled).length;
|
|
||||||
const docsEnforced = store
|
|
||||||
.filter((p) => p.enabled)
|
|
||||||
.reduce((sum, p) => sum + (runtime[p.id]?.stats.enforced ?? 0), 0);
|
|
||||||
const summary: PoliciesSummary = {
|
|
||||||
active,
|
|
||||||
paused,
|
|
||||||
categories: POLICY_CATEGORIES.length,
|
|
||||||
docsEnforced,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { summary, catalogue };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const policiesHandlers = [
|
export const policiesHandlers = [
|
||||||
// List — the catalogue (categories + configs + configured policies).
|
|
||||||
http.get("/api/v1/policies", async () => {
|
http.get("/api/v1/policies", async () => {
|
||||||
await delay(120);
|
await delay(120);
|
||||||
return HttpResponse.json(buildResponse());
|
return HttpResponse.json(store);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/api/v1/policies/runs", async () => {
|
||||||
|
await delay(120);
|
||||||
|
return HttpResponse.json(runs);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Get one stored policy by id (the raw wire record).
|
|
||||||
http.get("/api/v1/policies/:id", async ({ params }) => {
|
http.get("/api/v1/policies/:id", async ({ params }) => {
|
||||||
await delay(120);
|
await delay(120);
|
||||||
const policy = store.find((p) => p.id === params.id);
|
const policy = store.find((p) => p.id === params.id);
|
||||||
@@ -121,17 +60,17 @@ export const policiesHandlers = [
|
|||||||
return HttpResponse.json(policy);
|
return HttpResponse.json(policy);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Create or update — a blank id is assigned (create) or matched (update).
|
// Create or update — one policy per category: a create for a category that
|
||||||
// One policy per category: a create for a category that already has one
|
// already has one replaces it, matching the editor's contract.
|
||||||
// replaces it, matching the editor's "one policy per category, ever".
|
|
||||||
http.post("/api/v1/policies", async ({ request }) => {
|
http.post("/api/v1/policies", async ({ request }) => {
|
||||||
await delay(120);
|
await delay(120);
|
||||||
const incoming = (await request.json()) as Policy;
|
const incoming = (await request.json()) as WirePolicy;
|
||||||
|
const catId = categoryId(incoming);
|
||||||
const existing = incoming.id
|
const existing = incoming.id
|
||||||
? store.find((p) => p.id === incoming.id)
|
? store.find((p) => p.id === incoming.id)
|
||||||
: store.find((p) => p.categoryId === incoming.categoryId);
|
: store.find((p) => categoryId(p) === catId);
|
||||||
const id = existing?.id ?? nextId(incoming.categoryId);
|
const id = existing?.id ?? nextId(catId);
|
||||||
const saved: Policy = {
|
const saved: WirePolicy = {
|
||||||
...incoming,
|
...incoming,
|
||||||
id,
|
id,
|
||||||
owner: existing?.owner ?? "you@acme.com",
|
owner: existing?.owner ?? "you@acme.com",
|
||||||
@@ -139,45 +78,16 @@ export const policiesHandlers = [
|
|||||||
store = existing
|
store = existing
|
||||||
? store.map((p) => (p.id === id ? saved : p))
|
? store.map((p) => (p.id === id ? saved : p))
|
||||||
: [...store, saved];
|
: [...store, saved];
|
||||||
// Seed runtime for a brand-new policy so the detail panel has somewhere to
|
|
||||||
// read from; an update keeps whatever runtime it already had.
|
|
||||||
if (!runtime[id]) runtime[id] = emptyRuntime();
|
|
||||||
return HttpResponse.json(saved);
|
return HttpResponse.json(saved);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Delete a stored policy by id.
|
|
||||||
http.delete("/api/v1/policies/:id", async ({ params }) => {
|
http.delete("/api/v1/policies/:id", async ({ params }) => {
|
||||||
await delay(120);
|
await delay(120);
|
||||||
const id = String(params.id);
|
const id = String(params.id);
|
||||||
const existed = store.some((p) => p.id === id);
|
if (!store.some((p) => p.id === id))
|
||||||
if (!existed) return new HttpResponse(null, { status: 404 });
|
return new HttpResponse(null, { status: 404 });
|
||||||
store = store.filter((p) => p.id !== id);
|
store = store.filter((p) => p.id !== id);
|
||||||
delete runtime[id];
|
runs = runs.filter((r) => r.policyId !== id);
|
||||||
return new HttpResponse(null, { status: 204 });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Run a stored policy now. The real endpoint is multipart (files) and returns
|
|
||||||
// a run id; the portal has no files, so the mock just acknowledges with a run
|
|
||||||
// id and nudges the activity feed so the run is visible.
|
|
||||||
http.post("/api/v1/policies/:id/run", async ({ params }) => {
|
|
||||||
await delay(120);
|
|
||||||
const id = String(params.id);
|
|
||||||
const policy = store.find((p) => p.id === id);
|
|
||||||
if (!policy) return new HttpResponse(null, { status: 404 });
|
|
||||||
const rt = runtime[id] ?? emptyRuntime();
|
|
||||||
runtime[id] = {
|
|
||||||
...rt,
|
|
||||||
activity: [
|
|
||||||
{
|
|
||||||
doc: "manual-run.pdf",
|
|
||||||
action: "Enforcing…",
|
|
||||||
time: "just now",
|
|
||||||
status: "processing",
|
|
||||||
},
|
|
||||||
...rt.activity,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const runId = `run_${Date.now().toString(36)}`;
|
|
||||||
return HttpResponse.json({ status: true, fileId: runId, message: null });
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,200 +1,84 @@
|
|||||||
/**
|
/**
|
||||||
* Policies fixtures and the canonical TS model the portal shares with them.
|
* Policies fixtures and the canonical TS model the portal shares with them.
|
||||||
*
|
*
|
||||||
* The model mirrors the editor + backend policy contract so the portal's
|
* Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec
|
||||||
* "set up a policy" flow is plug-and-play against the real `/api/v1/policies`
|
* layer and match the backend record exactly. Catalogue and UI types
|
||||||
* API. A policy is a stored automation: an ordered chain of tool steps (each
|
* (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only:
|
||||||
* step's `operation` is a Stirling endpoint path) plus an output destination,
|
* the backend has no "category" concept — `categoryId` rides in
|
||||||
* fired automatically by a trigger (editor upload/export) over a set of
|
* `output.options`. The catalogue assembles client-side in `api/policies.ts`
|
||||||
* sources. The catalogue groups policies by category, each category carrying a
|
* from the decoded wire records + these static definitions.
|
||||||
* `PolicyConfigDef` (summary, rules, fields, default tool chain) the setup flow
|
|
||||||
* builds from.
|
|
||||||
*
|
*
|
||||||
* The wire types (`Policy`, `PipelineStep`) match the backend records exactly;
|
* api/policies.ts re-exports everything; components never reach in here.
|
||||||
* the catalogue + decorated state shapes (`PolicyConfigDef`, `PolicyField`,
|
|
||||||
* `PolicyState`, …) are lifted from the editor's `types/policies.ts`, with
|
|
||||||
* ReactNode icons replaced by string icon keys (the portal renders its own).
|
|
||||||
*
|
|
||||||
* api/policies.ts re-exports these types; the MSW handlers serve the fixture
|
|
||||||
* data over intercepted apiClient.local.json() calls. Components never reach in here.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
import type { WirePipelineStep, WirePolicy } from "@shared/policies/types";
|
||||||
/* Backend wire model — matches Policy.java / PipelineStep.java exactly */
|
import type { PolicyRunView } from "@shared/policies/types";
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/**
|
export type {
|
||||||
* A single tool invocation in a policy's pipeline. `operation` is a Stirling
|
PolicyActivityItem,
|
||||||
* endpoint path (e.g. `/api/v1/security/auto-redact`); `parameters` are the
|
PolicyDecodedState,
|
||||||
* scalar form fields that endpoint accepts. `fileParameters` binds a tool's
|
PolicyRunStatus,
|
||||||
* named file field to an asset key in a run's supporting-file store.
|
PolicyRunView,
|
||||||
*/
|
PolicyStats,
|
||||||
export interface PipelineStep {
|
WireOutputOptions,
|
||||||
operation: string;
|
WireOutputSpec,
|
||||||
parameters: Record<string, unknown>;
|
WirePipelineStep,
|
||||||
fileParameters?: Record<string, string>;
|
WirePolicy,
|
||||||
}
|
} from "@shared/policies/types";
|
||||||
|
|
||||||
/** When a policy fires automatically. A null trigger means manual-only. */
|
|
||||||
export interface TriggerConfig {
|
|
||||||
/** The editor event the policy runs on. */
|
|
||||||
event: "upload" | "export";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a policy's documents come from (a connected source). */
|
|
||||||
export interface InputSpec {
|
|
||||||
/** Source id from {@link POLICY_SOURCES}. */
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How a run's result is delivered. */
|
|
||||||
export interface OutputSpec {
|
|
||||||
/** A separate new file, or a new version of the input the policy ran on. */
|
|
||||||
mode: "new_file" | "new_version";
|
|
||||||
/** Rename rule for the output; empty keeps the input filename. */
|
|
||||||
name: string;
|
|
||||||
namePosition: "prefix" | "suffix" | "auto-number";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored policy record — the exact JSON body the backend returns from
|
|
||||||
* `GET /api/v1/policies` and accepts on `POST /api/v1/policies`. The portal
|
|
||||||
* decorates this with catalogue + runtime data for display (see {@link decorate}).
|
|
||||||
*/
|
|
||||||
export interface Policy {
|
|
||||||
/** Blank on create; the backend assigns one and returns it. */
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
/** Server-assigned owner; the client never forges it. */
|
|
||||||
owner?: string;
|
|
||||||
/** Whether the trigger fires automatically. Pausing flips this. */
|
|
||||||
enabled: boolean;
|
|
||||||
trigger: TriggerConfig | null;
|
|
||||||
sources: InputSpec[];
|
|
||||||
steps: PipelineStep[];
|
|
||||||
output: OutputSpec;
|
|
||||||
/** The category this policy belongs to. Drives catalogue grouping. */
|
|
||||||
categoryId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
/* Catalogue model — lifted from editor types/policies.ts */
|
/* Catalogue model — portal-specific (lifted from editor types/policies.ts) */
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export type PolicyStatus = "default" | "active" | "paused";
|
export type PolicyStatus = "active" | "paused";
|
||||||
|
|
||||||
/** Derived display status for a card/detail. */
|
|
||||||
export type PolicyRowStatus = "active" | "paused" | "setup";
|
export type PolicyRowStatus = "active" | "paused" | "setup";
|
||||||
|
|
||||||
/** A configurable field within a policy's settings. */
|
|
||||||
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
|
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
|
||||||
|
|
||||||
export interface PolicyField {
|
export interface PolicyField {
|
||||||
label: string;
|
label: string;
|
||||||
key: string;
|
key: string;
|
||||||
type: PolicyFieldType;
|
type: PolicyFieldType;
|
||||||
/** Default value: boolean (toggle), string (select/text), string[] (chips). */
|
|
||||||
value: boolean | string | string[];
|
value: boolean | string | string[];
|
||||||
/** Options for select/chips. */
|
|
||||||
options?: string[];
|
options?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Static definition of a policy category. The editor's `icon: ReactNode` is
|
|
||||||
* replaced by a string `icon` key the portal resolves to its own glyph.
|
|
||||||
*/
|
|
||||||
export interface PolicyCategory {
|
export interface PolicyCategory {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** Icon key the portal renders (not a component — the portal owns glyphs). */
|
|
||||||
icon: string;
|
icon: string;
|
||||||
/** Visual tone for the category's icon chip. */
|
|
||||||
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
||||||
/** Long description shown in the setup flow. */
|
|
||||||
desc: string;
|
desc: string;
|
||||||
/** Drives the "Set up Classification" affordance (doc-type narrowing). */
|
|
||||||
providesClassification?: boolean;
|
providesClassification?: boolean;
|
||||||
/** Locked "Coming soon" — can't be opened or configured. */
|
|
||||||
comingSoon?: boolean;
|
comingSoon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The narrative + field configuration backing a category. */
|
|
||||||
export interface PolicyConfigDef {
|
export interface PolicyConfigDef {
|
||||||
/** One-line summary of what the policy enforces. */
|
|
||||||
summary: string;
|
summary: string;
|
||||||
/** Pipeline-like rule chips shown in the "Enforces" section. */
|
|
||||||
rules: string[];
|
rules: string[];
|
||||||
/** Human label for the scope this policy applies to. */
|
|
||||||
scopeLabel: string;
|
scopeLabel: string;
|
||||||
/** Editable policy-level settings fields. */
|
|
||||||
fields: PolicyField[];
|
fields: PolicyField[];
|
||||||
/**
|
defaultOperations: WirePipelineStep[];
|
||||||
* The preset pipeline a new policy is seeded with — the real, editable tool
|
|
||||||
* steps (each `operation` is a Stirling endpoint path, matching the backend's
|
|
||||||
* PipelineStep). The setup flow starts from these.
|
|
||||||
*/
|
|
||||||
defaultOperations: PipelineStep[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A source a policy can run over (setup "Sources" step). */
|
|
||||||
export interface PolicySource {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
desc: string;
|
|
||||||
/** Icon key the portal renders. */
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Three-up summary stats shown at the foot of a configured policy's detail. */
|
|
||||||
export interface PolicyStats {
|
|
||||||
/** Documents enforced. */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An entry in a policy's recent-activity feed. */
|
|
||||||
export interface PolicyActivityItem {
|
|
||||||
/** Document the policy acted on. */
|
|
||||||
doc: string;
|
|
||||||
/** What the policy did, e.g. "Redacted 4 PII matches • 2 pages". */
|
|
||||||
action: string;
|
|
||||||
/** Relative timestamp, e.g. "2h ago". */
|
|
||||||
time: string;
|
|
||||||
/** "enforced" (clean), "flagged" (needs review), "processing" (running). */
|
|
||||||
status: "enforced" | "flagged" | "processing";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The collected settings the setup flow gathers and the detail panel reads —
|
|
||||||
* the editor's `PolicyState`, minus the local-cache bookkeeping (folderId etc).
|
|
||||||
*/
|
|
||||||
export interface PolicyState {
|
export interface PolicyState {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
status: PolicyStatus;
|
status: PolicyStatus;
|
||||||
/** Selected sources (ids from {@link POLICY_SOURCES}). */
|
|
||||||
sources: string[];
|
sources: string[];
|
||||||
/** When non-empty, narrows the policy to these document types. */
|
|
||||||
scopeTypes: string[];
|
scopeTypes: string[];
|
||||||
/** Email low-confidence enforcements are routed to. */
|
|
||||||
reviewerEmail: string;
|
reviewerEmail: string;
|
||||||
/** Saved field values, keyed by field key (overrides the definition default). */
|
|
||||||
fieldValues: Record<string, boolean | string | string[]>;
|
fieldValues: Record<string, boolean | string | string[]>;
|
||||||
/** How a run's output is delivered. Defaults to "new_version". */
|
|
||||||
outputMode?: "new_file" | "new_version";
|
outputMode?: "new_file" | "new_version";
|
||||||
/** Rename rule for the output. Empty keeps the input filename. */
|
|
||||||
outputName?: string;
|
outputName?: string;
|
||||||
/** When the policy runs. Defaults to "upload". */
|
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||||
runOn?: "upload" | "export";
|
runOn?: "upload" | "export";
|
||||||
/** Backend record id once persisted; used to update/delete/run it. */
|
maxRetries?: number;
|
||||||
|
retryDelayMinutes?: number;
|
||||||
backendId?: string;
|
backendId?: string;
|
||||||
/** A shipped catalogue policy (configurable but not deletable). */
|
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the setup flow hands back on submit — collected settings + built steps. */
|
|
||||||
export interface PolicySetupResult {
|
export interface PolicySetupResult {
|
||||||
fieldValues: Record<string, boolean | string | string[]>;
|
fieldValues: Record<string, boolean | string | string[]>;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
@@ -204,57 +88,40 @@ export interface PolicySetupResult {
|
|||||||
outputName: string;
|
outputName: string;
|
||||||
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
||||||
runOn: "upload" | "export";
|
runOn: "upload" | "export";
|
||||||
/** The configured tool chain as backend pipeline steps. */
|
maxRetries: number;
|
||||||
steps: PipelineStep[];
|
retryDelayMinutes: number;
|
||||||
|
steps: WirePipelineStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A configured policy as the catalogue view consumes it: the wire record plus
|
|
||||||
* the catalogue's category/config and derived runtime data. The handlers build
|
|
||||||
* this from the in-memory store + fixtures.
|
|
||||||
*/
|
|
||||||
export interface DecoratedPolicy {
|
export interface DecoratedPolicy {
|
||||||
category: PolicyCategory;
|
category: PolicyCategory;
|
||||||
config: PolicyConfigDef;
|
config: PolicyConfigDef;
|
||||||
state: PolicyState;
|
state: PolicyState;
|
||||||
/** The policy's configured steps (drives the detail "Enforces" flow). */
|
steps: WirePipelineStep[];
|
||||||
steps: PipelineStep[];
|
stats: import("@shared/policies/types").PolicyStats;
|
||||||
stats: PolicyStats;
|
activity: import("@shared/policies/types").PolicyActivityItem[];
|
||||||
activity: PolicyActivityItem[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Catalogue strip totals shown above the cards. */
|
|
||||||
export interface PoliciesSummary {
|
export interface PoliciesSummary {
|
||||||
/** Policies currently active (enabled). */
|
|
||||||
active: number;
|
active: number;
|
||||||
/** Policies configured but paused. */
|
|
||||||
paused: number;
|
paused: number;
|
||||||
/** Categories available to configure. */
|
|
||||||
categories: number;
|
categories: number;
|
||||||
/** Documents enforced across all active policies. */
|
|
||||||
docsEnforced: number;
|
docsEnforced: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `GET /api/v1/policies` response, in the portal's catalogue shape. */
|
|
||||||
export interface PoliciesResponse {
|
export interface PoliciesResponse {
|
||||||
summary: PoliciesSummary;
|
summary: PoliciesSummary;
|
||||||
/** Every catalogue category, each with its definition + (optional) state. */
|
|
||||||
catalogue: CatalogueEntry[];
|
catalogue: CatalogueEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One catalogue row: a category, its definition, and its current state. */
|
|
||||||
export interface CatalogueEntry {
|
export interface CatalogueEntry {
|
||||||
category: PolicyCategory;
|
category: PolicyCategory;
|
||||||
config: PolicyConfigDef;
|
config: PolicyConfigDef;
|
||||||
/** The configured policy's runtime view, or null when not yet set up. */
|
|
||||||
policy: DecoratedPolicy | null;
|
policy: DecoratedPolicy | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
/* Tool → endpoint registry */
|
/* Tool → endpoint registry */
|
||||||
/* Maps a frontend tool id to its Stirling endpoint path. The setup flow's */
|
|
||||||
/* pipeline steps carry endpoint paths (the backend's PipelineStep contract), */
|
|
||||||
/* so this is the seam that keeps the preset chains plug-and-play. */
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||||
@@ -266,7 +133,6 @@ export const TOOL_ENDPOINTS: Record<string, string> = {
|
|||||||
compress: "/api/v1/misc/compress-pdf",
|
compress: "/api/v1/misc/compress-pdf",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A friendly label for an endpoint path (for the detail "Enforces" chips). */
|
|
||||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||||
"/api/v1/security/auto-redact": "Redact PII",
|
"/api/v1/security/auto-redact": "Redact PII",
|
||||||
"/api/v1/security/sanitize-pdf": "Remove JavaScript",
|
"/api/v1/security/sanitize-pdf": "Remove JavaScript",
|
||||||
@@ -276,7 +142,6 @@ export const ENDPOINT_LABELS: Record<string, string> = {
|
|||||||
"/api/v1/misc/compress-pdf": "Compress",
|
"/api/v1/misc/compress-pdf": "Compress",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** "/api/v1/security/auto-redact" → "Auto Redact" — fallback humanisation. */
|
|
||||||
export function humanizeEndpoint(path: string): string {
|
export function humanizeEndpoint(path: string): string {
|
||||||
if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path];
|
if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path];
|
||||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||||
@@ -287,15 +152,12 @@ export function humanizeEndpoint(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
/* Catalogue definitions — categories, configs, sources, doc types */
|
/* Catalogue definitions */
|
||||||
/* Modelled on the editor's policyDefinitions. PII redact regexes are the */
|
|
||||||
/* precise patterns the /auto-redact endpoint matches (wordsToRedact). */
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** PII regexes seeded into a Security policy's redact step (SSN + cards). */
|
|
||||||
const DEFAULT_PII_PATTERNS: string[] = [
|
const DEFAULT_PII_PATTERNS: string[] = [
|
||||||
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", // SSN
|
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b",
|
||||||
"\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b", // cards
|
"\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const POLICY_CATEGORIES: PolicyCategory[] = [
|
export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||||
@@ -371,10 +233,8 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
|||||||
security: {
|
security: {
|
||||||
summary:
|
summary:
|
||||||
"Detects and redacts PII, strips active content (JavaScript), and watermarks documents.",
|
"Detects and redacts PII, strips active content (JavaScript), and watermarks documents.",
|
||||||
rules: ["Redact PII", "Remove JavaScript"],
|
rules: ["Redact PII", "Remove JavaScript", "Watermark"],
|
||||||
scopeLabel: "All documents",
|
scopeLabel: "All documents",
|
||||||
// Default chain: redact PII (flattened to image so text is truly removed) +
|
|
||||||
// strip JavaScript. Watermark is offered in the designer but off by default.
|
|
||||||
defaultOperations: [
|
defaultOperations: [
|
||||||
{
|
{
|
||||||
operation: TOOL_ENDPOINTS.redact,
|
operation: TOOL_ENDPOINTS.redact,
|
||||||
@@ -395,9 +255,14 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
|||||||
removeFonts: false,
|
removeFonts: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
operation: TOOL_ENDPOINTS.watermark,
|
||||||
|
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||||
|
parameters: {
|
||||||
|
convertPDFToImage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// The tool chain is configured per-tool in the designer (redact / sanitize /
|
|
||||||
// watermark); no separate policy-level fields.
|
|
||||||
fields: [],
|
fields: [],
|
||||||
},
|
},
|
||||||
compliance: {
|
compliance: {
|
||||||
@@ -482,45 +347,6 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POLICY_SOURCES: PolicySource[] = [
|
|
||||||
{
|
|
||||||
id: "editor",
|
|
||||||
label: "Editor",
|
|
||||||
desc: "Documents you save or export in Stirling",
|
|
||||||
icon: "file",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "device",
|
|
||||||
label: "Entire device",
|
|
||||||
desc: "All PDFs on this machine, retroactively",
|
|
||||||
icon: "device",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sharepoint",
|
|
||||||
label: "SharePoint",
|
|
||||||
desc: "Connected SharePoint libraries",
|
|
||||||
icon: "globe",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "dropbox",
|
|
||||||
label: "Dropbox",
|
|
||||||
desc: "Connected Dropbox folders",
|
|
||||||
icon: "cloud",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gmail",
|
|
||||||
label: "Gmail",
|
|
||||||
desc: "PDF attachments in email",
|
|
||||||
icon: "mail",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gdrive",
|
|
||||||
label: "Google Drive",
|
|
||||||
desc: "Connected Drive folders",
|
|
||||||
icon: "folder",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const POLICY_DOC_TYPES: string[] = [
|
export const POLICY_DOC_TYPES: string[] = [
|
||||||
"Contracts",
|
"Contracts",
|
||||||
"Invoices",
|
"Invoices",
|
||||||
@@ -533,80 +359,86 @@ export const POLICY_DOC_TYPES: string[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
/* Seed policies — a few configured policies in the wire shape, so the store */
|
/* Seed data — real backend wire format */
|
||||||
/* behaves like a backend that already has policies set up. */
|
|
||||||
/* ──────────────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The shipped default policies the handlers seed the store with. */
|
export function seedPolicies(): WirePolicy[] {
|
||||||
export function seedPolicies(): Policy[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "pol_security_default",
|
id: "pol_security_default",
|
||||||
name: "Security Policy",
|
name: "Security Policy",
|
||||||
owner: "security@acme.com",
|
owner: "security@acme.com",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trigger: { event: "upload" },
|
trigger: null,
|
||||||
sources: [{ source: "editor" }],
|
|
||||||
steps: POLICY_CONFIG.security.defaultOperations,
|
steps: POLICY_CONFIG.security.defaultOperations,
|
||||||
output: { mode: "new_version", name: "", namePosition: "suffix" },
|
output: {
|
||||||
categoryId: "security",
|
type: "inline",
|
||||||
|
options: {
|
||||||
|
runOn: "upload",
|
||||||
|
mode: "new_version",
|
||||||
|
name: "",
|
||||||
|
position: "suffix",
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelayMinutes: 5,
|
||||||
|
categoryId: "security",
|
||||||
|
sources: ["src-claims"],
|
||||||
|
scopeTypes: [],
|
||||||
|
reviewerEmail: "security@acme.com",
|
||||||
|
fieldValues: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const NOW = Date.now();
|
||||||
* Per-policy runtime extras keyed by policy id — the parts the wire record
|
const M = 60000;
|
||||||
* doesn't carry (collected field values, scope, derived stats + activity).
|
const H = 3600000;
|
||||||
* In a real backend these would be derived server-side from the user's files.
|
const D = 86400000;
|
||||||
*/
|
|
||||||
export interface PolicyRuntime {
|
|
||||||
scopeTypes: string[];
|
|
||||||
reviewerEmail: string;
|
|
||||||
fieldValues: Record<string, boolean | string | string[]>;
|
|
||||||
stats: PolicyStats;
|
|
||||||
activity: PolicyActivityItem[];
|
|
||||||
isDefault?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function seedRuntime(): Record<string, PolicyRuntime> {
|
/** Seed `PolicyRunView` records that drive the activity feed + stats. */
|
||||||
return {
|
export function seedPolicyRuns(): PolicyRunView[] {
|
||||||
pol_security_default: {
|
return [
|
||||||
scopeTypes: [],
|
{
|
||||||
reviewerEmail: "security@acme.com",
|
runId: "run_001",
|
||||||
fieldValues: {},
|
policyId: "pol_security_default",
|
||||||
isDefault: true,
|
status: "COMPLETED",
|
||||||
stats: { enforced: 4821, dataProcessed: "2.3 GB", activeFor: "34d" },
|
currentStep: 2,
|
||||||
activity: [
|
stepCount: 2,
|
||||||
{
|
error: null,
|
||||||
doc: "Q2-vendor-agreement.pdf",
|
outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }],
|
||||||
action: "Redacted 6 PII matches • JavaScript stripped",
|
createdAt: NOW - 12 * M,
|
||||||
time: "12m ago",
|
|
||||||
status: "enforced",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
doc: "patient-intake-0481.pdf",
|
|
||||||
action: "Low-confidence match — routed for review",
|
|
||||||
time: "1h ago",
|
|
||||||
status: "flagged",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
doc: "invoice-7782.pdf",
|
|
||||||
action: "Enforcing…",
|
|
||||||
time: "just now",
|
|
||||||
status: "processing",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
}
|
runId: "run_002",
|
||||||
|
policyId: "pol_security_default",
|
||||||
/** Empty stats/activity for a freshly-configured policy with no runs yet. */
|
status: "FAILED",
|
||||||
export function emptyRuntime(reviewerEmail = "you@acme.com"): PolicyRuntime {
|
currentStep: 1,
|
||||||
return {
|
stepCount: 2,
|
||||||
scopeTypes: [],
|
error: "Low-confidence match — routed for review",
|
||||||
reviewerEmail,
|
outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }],
|
||||||
fieldValues: {},
|
createdAt: NOW - 1 * H,
|
||||||
stats: { enforced: 0, dataProcessed: "0 B", activeFor: "—" },
|
},
|
||||||
activity: [],
|
{
|
||||||
};
|
runId: "run_003",
|
||||||
|
policyId: "pol_security_default",
|
||||||
|
status: "RUNNING",
|
||||||
|
currentStep: 1,
|
||||||
|
stepCount: 2,
|
||||||
|
error: null,
|
||||||
|
outputs: [{ fileId: "f3", fileName: "invoice-7782.pdf" }],
|
||||||
|
createdAt: NOW - 2 * M,
|
||||||
|
},
|
||||||
|
// Older completed runs for stats
|
||||||
|
...Array.from({ length: 4818 }, (_, i) => ({
|
||||||
|
runId: `run_old_${i}`,
|
||||||
|
policyId: "pol_security_default",
|
||||||
|
status: "COMPLETED" as const,
|
||||||
|
currentStep: 2,
|
||||||
|
stepCount: 2,
|
||||||
|
error: null,
|
||||||
|
outputs: [] as { fileId: string; fileName: string }[],
|
||||||
|
createdAt: NOW - (34 * D + i * 10 * M),
|
||||||
|
})),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,21 +31,16 @@
|
|||||||
|
|
||||||
/* Category grid */
|
/* Category grid */
|
||||||
.portal-policies__grid {
|
.portal-policies__grid {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 56rem) {
|
|
||||||
.portal-policies__grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Category card */
|
|
||||||
.portal-policies__card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category card — horizontal table row */
|
||||||
|
.portal-policies__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +48,47 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__card-head {
|
.portal-policies__card-identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
gap: 0.625rem;
|
gap: 0.125rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__card-enforces {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__card-statpair {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__card-statval {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__card-statlbl {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__cat-icon {
|
.portal-policies__cat-icon {
|
||||||
@@ -66,41 +98,8 @@
|
|||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: var(--radius-md);
|
font-size: 1.125rem;
|
||||||
font-size: 1rem;
|
color: var(--color-text-2);
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__cat-icon--neutral {
|
|
||||||
background: var(--color-bg-subtle);
|
|
||||||
color: var(--color-text-3);
|
|
||||||
}
|
|
||||||
.portal-policies__cat-icon--blue {
|
|
||||||
background: var(--color-blue-light);
|
|
||||||
color: var(--color-blue);
|
|
||||||
}
|
|
||||||
.portal-policies__cat-icon--purple {
|
|
||||||
background: var(--color-purple-light);
|
|
||||||
color: var(--color-purple);
|
|
||||||
}
|
|
||||||
.portal-policies__cat-icon--green {
|
|
||||||
background: var(--color-green-light);
|
|
||||||
color: var(--color-green-dark);
|
|
||||||
}
|
|
||||||
.portal-policies__cat-icon--amber {
|
|
||||||
background: var(--color-amber-light);
|
|
||||||
color: var(--color-amber-dark);
|
|
||||||
}
|
|
||||||
.portal-policies__cat-icon--red {
|
|
||||||
background: var(--color-red-light);
|
|
||||||
color: var(--color-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__card-titles {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__card-title {
|
.portal-policies__card-title {
|
||||||
@@ -110,50 +109,6 @@
|
|||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__card-blurb {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-4);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__card-summary {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--color-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__card-foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: auto;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__card-rules {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__card-cta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Configured-card stat footer */
|
|
||||||
.portal-policies__card-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
border-top: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wizard + detail shared chrome */
|
/* Wizard + detail shared chrome */
|
||||||
.portal-policies__wizard-title {
|
.portal-policies__wizard-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -196,6 +151,13 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-policies__wizard-subheading {
|
||||||
|
margin: 0.875rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
.portal-policies__fields {
|
.portal-policies__fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -225,17 +187,6 @@
|
|||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__tool-endpoint {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--color-text-4);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sources picker */
|
/* Sources picker */
|
||||||
.portal-policies__sources {
|
.portal-policies__sources {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -251,12 +202,12 @@
|
|||||||
|
|
||||||
.portal-policies__source {
|
.portal-policies__source {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.625rem;
|
padding: 0.625rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -264,6 +215,22 @@
|
|||||||
background var(--motion-fast);
|
background var(--motion-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-policies__source::after {
|
||||||
|
content: "";
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid var(--color-border-strong);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast),
|
||||||
|
background var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
.portal-policies__source:hover {
|
.portal-policies__source:hover {
|
||||||
background: var(--color-bg-hover);
|
background: var(--color-bg-hover);
|
||||||
}
|
}
|
||||||
@@ -273,12 +240,26 @@
|
|||||||
background: var(--color-blue-light);
|
background: var(--color-blue-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-policies__source--on::after {
|
||||||
|
content: "✓";
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-blue);
|
||||||
|
background: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
.portal-policies__source-icon {
|
.portal-policies__source-icon {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-policies__source--on .portal-policies__source-icon {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
.portal-policies__source-text {
|
.portal-policies__source-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -338,8 +319,13 @@
|
|||||||
.portal-policies__detail-status {
|
.portal-policies__detail-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__detail-sep {
|
||||||
|
color: var(--color-text-5);
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__detail-meta {
|
.portal-policies__detail-meta {
|
||||||
@@ -347,28 +333,31 @@
|
|||||||
color: var(--color-text-4);
|
color: var(--color-text-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__enforce-flow {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__enforce-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-policies__enforce-arrow {
|
.portal-policies__enforce-arrow {
|
||||||
color: var(--color-text-5);
|
color: var(--color-text-5);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__enforce-note {
|
.portal-policies__detail-inline {
|
||||||
margin: 0.75rem 0 0;
|
display: flex;
|
||||||
font-size: 0.75rem;
|
align-items: baseline;
|
||||||
|
gap: 0.625rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__detail-inline-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-4);
|
color: var(--color-text-4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__detail-inline-value {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-2);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,22 +373,32 @@
|
|||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__activity-dot {
|
.portal-policies__activity-icon {
|
||||||
width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.375rem;
|
margin-top: 0.125rem;
|
||||||
border-radius: 50%;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__activity-dot--success {
|
.portal-policies__activity-icon--success {
|
||||||
background: var(--color-green);
|
color: var(--color-green);
|
||||||
}
|
}
|
||||||
.portal-policies__activity-dot--warning {
|
.portal-policies__activity-icon--warning {
|
||||||
background: var(--color-amber);
|
color: var(--color-amber);
|
||||||
}
|
}
|
||||||
.portal-policies__activity-dot--info {
|
.portal-policies__activity-icon--info {
|
||||||
background: var(--color-blue);
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes portal-policies-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__activity-spin {
|
||||||
|
animation: portal-policies-spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-policies__activity-text {
|
.portal-policies__activity-text {
|
||||||
@@ -427,6 +426,29 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-policies__activity-retry {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error expand/collapse for long activity messages */
|
||||||
|
.portal-policies__activity-error {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__activity-error-text--clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-policies__activity-error-toggle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Detail stats footer */
|
/* Detail stats footer */
|
||||||
.portal-policies__detail-stats {
|
.portal-policies__detail-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Skeleton } from "@shared/components";
|
import { Banner, Button, Skeleton } from "@shared/components";
|
||||||
|
import { errorMessage } from "@portal/api/http";
|
||||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||||
import {
|
import {
|
||||||
|
buildWireFromSetup,
|
||||||
|
buildWireFromState,
|
||||||
deletePolicy,
|
deletePolicy,
|
||||||
fetchPolicies,
|
fetchPolicies,
|
||||||
runPolicy,
|
|
||||||
savePolicy,
|
savePolicy,
|
||||||
|
POLICY_CATEGORIES,
|
||||||
|
POLICY_CONFIG,
|
||||||
type CatalogueEntry,
|
type CatalogueEntry,
|
||||||
type PoliciesResponse,
|
type PoliciesResponse,
|
||||||
type Policy,
|
|
||||||
type PolicySetupResult,
|
type PolicySetupResult,
|
||||||
} from "@portal/api/policies";
|
} from "@portal/api/policies";
|
||||||
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
|
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
|
||||||
@@ -18,51 +21,36 @@ import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel
|
|||||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||||
import "@portal/views/Policies.css";
|
import "@portal/views/Policies.css";
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate the setup flow's collected result into the backend `Policy` wire
|
|
||||||
* record (Policy.java): the tool chain becomes the ordered pipeline `steps`,
|
|
||||||
* the run event becomes the `trigger`, and the output settings become `output`.
|
|
||||||
* Reuses the existing record's id on edit so the POST updates in place.
|
|
||||||
*/
|
|
||||||
function toWirePolicy(
|
|
||||||
entry: CatalogueEntry,
|
|
||||||
result: PolicySetupResult,
|
|
||||||
): Policy {
|
|
||||||
return {
|
|
||||||
id: entry.policy?.state.backendId ?? "",
|
|
||||||
name: `${entry.category.label} Policy`,
|
|
||||||
enabled: entry.policy ? entry.policy.state.status !== "paused" : true,
|
|
||||||
trigger: { event: result.runOn },
|
|
||||||
sources: result.sources.map((source) => ({ source })),
|
|
||||||
steps: result.steps,
|
|
||||||
output: {
|
|
||||||
mode: result.outputMode,
|
|
||||||
name: result.outputName,
|
|
||||||
namePosition: result.outputNamePosition,
|
|
||||||
},
|
|
||||||
categoryId: entry.category.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Policies() {
|
export function Policies() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The catalogue is refetched after every mutation by bumping this counter,
|
|
||||||
// so the cards/detail reflect the in-memory store the handlers maintain.
|
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), [version]);
|
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), [version]);
|
||||||
const { data, loading } = state;
|
const { data, loading, error: fetchError } = state;
|
||||||
const { isLoading } = useSectionFlags(state);
|
const { isLoading } = useSectionFlags(state);
|
||||||
|
|
||||||
// The category whose detail panel is open (configured), and the one whose
|
|
||||||
// setup wizard is open. Both reference a catalogue entry.
|
|
||||||
const [detail, setDetail] = useState<CatalogueEntry | null>(null);
|
const [detail, setDetail] = useState<CatalogueEntry | null>(null);
|
||||||
const [wizard, setWizard] = useState<CatalogueEntry | null>(null);
|
const [wizard, setWizard] = useState<CatalogueEntry | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
|
||||||
const catalogue = data?.catalogue ?? [];
|
const catalogue = data?.catalogue ?? [];
|
||||||
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
||||||
|
|
||||||
// Open the detail panel for configured categories, the wizard otherwise.
|
const displayCatalogue: CatalogueEntry[] =
|
||||||
|
catalogue.length > 0
|
||||||
|
? catalogue
|
||||||
|
: POLICY_CATEGORIES.map((cat) => ({
|
||||||
|
category: cat,
|
||||||
|
config: POLICY_CONFIG[cat.id] ?? {
|
||||||
|
summary: "",
|
||||||
|
rules: [],
|
||||||
|
scopeLabel: "",
|
||||||
|
fields: [],
|
||||||
|
defaultOperations: [],
|
||||||
|
},
|
||||||
|
policy: null,
|
||||||
|
}));
|
||||||
|
|
||||||
function openEntry(entry: CatalogueEntry) {
|
function openEntry(entry: CatalogueEntry) {
|
||||||
if (entry.policy) setDetail(entry);
|
if (entry.policy) setDetail(entry);
|
||||||
else setWizard(entry);
|
else setWizard(entry);
|
||||||
@@ -72,50 +60,39 @@ export function Policies() {
|
|||||||
entry: CatalogueEntry,
|
entry: CatalogueEntry,
|
||||||
result: PolicySetupResult,
|
result: PolicySetupResult,
|
||||||
) {
|
) {
|
||||||
await savePolicy(toWirePolicy(entry, result));
|
setPageError(null);
|
||||||
setWizard(null);
|
try {
|
||||||
setDetail(null);
|
await savePolicy(buildWireFromSetup(entry, result));
|
||||||
refetch();
|
setWizard(null);
|
||||||
|
setDetail(null);
|
||||||
|
refetch();
|
||||||
|
} catch (e) {
|
||||||
|
setPageError(errorMessage(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLifecycle(action: () => Promise<unknown>) {
|
async function runLifecycle(action: () => Promise<unknown>) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
setPageError(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
refetch();
|
refetch();
|
||||||
|
} catch (e) {
|
||||||
|
setPageError(errorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRun() {
|
|
||||||
const id = detail?.policy?.state.backendId;
|
|
||||||
if (id) void runLifecycle(() => runPolicy(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTogglePause() {
|
function handleTogglePause() {
|
||||||
const entry = detail;
|
const entry = detail;
|
||||||
const policy = entry?.policy;
|
const policy = entry?.policy;
|
||||||
if (!entry || !policy?.state.backendId) return;
|
if (!entry || !policy?.state.backendId) return;
|
||||||
// Pause/resume is a re-save with the enabled flag flipped (the backend has
|
const enabled = policy.state.status === "paused";
|
||||||
// no dedicated endpoint — every mutation routes through POST /policies).
|
|
||||||
void runLifecycle(() =>
|
void runLifecycle(() =>
|
||||||
savePolicy({
|
savePolicy(buildWireFromState(entry, policy, enabled)),
|
||||||
id: policy.state.backendId!,
|
|
||||||
name: `${entry.category.label} Policy`,
|
|
||||||
enabled: policy.state.status === "paused",
|
|
||||||
trigger: { event: policy.state.runOn ?? "upload" },
|
|
||||||
sources: policy.state.sources.map((source) => ({ source })),
|
|
||||||
steps: policy.steps,
|
|
||||||
output: {
|
|
||||||
mode: policy.state.outputMode ?? "new_version",
|
|
||||||
name: policy.state.outputName ?? "",
|
|
||||||
namePosition: "suffix",
|
|
||||||
},
|
|
||||||
categoryId: entry.category.id,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +101,6 @@ export function Policies() {
|
|||||||
if (id) void runLifecycle(() => deletePolicy(id));
|
if (id) void runLifecycle(() => deletePolicy(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reopen the wizard for the policy currently shown in the detail panel.
|
|
||||||
function handleEdit() {
|
function handleEdit() {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
setWizard(detail);
|
setWizard(detail);
|
||||||
@@ -139,19 +115,34 @@ export function Policies() {
|
|||||||
<p className="portal-policies__sub">{t("policies.subtitle")}</p>
|
<p className="portal-policies__sub">{t("policies.subtitle")}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{pageError && <Banner tone="danger" description={pageError} />}
|
||||||
|
|
||||||
<CatalogueSummary data={data} loading={loading} />
|
<CatalogueSummary data={data} loading={loading} />
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="portal-policies__grid" aria-hidden>
|
<div className="portal-policies__grid" aria-hidden>
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Skeleton key={i} height="11rem" />
|
<Skeleton key={i} height="3.5rem" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && catalogue.length > 0 && (
|
{!isLoading && fetchError && (
|
||||||
|
<Banner
|
||||||
|
tone="warning"
|
||||||
|
title={t("policies.offline.title")}
|
||||||
|
description={t("policies.offline.description")}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" onClick={refetch}>
|
||||||
|
{t("policies.offline.retry")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !fetchError && (
|
||||||
<div className="portal-policies__grid">
|
<div className="portal-policies__grid">
|
||||||
{catalogue.map((entry) => (
|
{displayCatalogue.map((entry) => (
|
||||||
<PolicyCategoryCard
|
<PolicyCategoryCard
|
||||||
key={entry.category.id}
|
key={entry.category.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
@@ -166,7 +157,6 @@ export function Policies() {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
onClose={() => setDetail(null)}
|
onClose={() => setDetail(null)}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onRun={handleRun}
|
|
||||||
onTogglePause={handleTogglePause}
|
onTogglePause={handleTogglePause}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,12 +23,19 @@ export default defineConfig({
|
|||||||
"@shared": sharedDir,
|
"@shared": sharedDir,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
// Allow Vite to serve files from the shared/ sibling directory when
|
||||||
|
// running tests with --root portal (which would otherwise block them).
|
||||||
|
allow: [".."],
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: ["./src/setupTests.ts"],
|
setupFiles: ["./src/setupTests.ts"],
|
||||||
css: false,
|
css: false,
|
||||||
include: ["src/**/*.test.{ts,tsx}"],
|
include: ["src/**/*.test.{ts,tsx}", "../shared/**/*.test.ts"],
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
hookTimeout: 10000,
|
hookTimeout: 10000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { toWirePolicy, fromWirePolicy } from "@shared/policies/codec";
|
||||||
|
import type { PolicyDecodedState } from "@shared/policies/types";
|
||||||
|
|
||||||
|
const FULL_STATE: PolicyDecodedState = {
|
||||||
|
id: "pol_123",
|
||||||
|
name: "Security Policy",
|
||||||
|
enabled: true,
|
||||||
|
categoryId: "security",
|
||||||
|
sources: ["editor", "gdrive"],
|
||||||
|
scopeTypes: ["Contracts", "Invoices"],
|
||||||
|
reviewerEmail: "admin@example.com",
|
||||||
|
fieldValues: { auditTrail: true, frameworks: ["HIPAA"] },
|
||||||
|
runOn: "upload",
|
||||||
|
outputMode: "new_version",
|
||||||
|
outputName: "redacted",
|
||||||
|
outputNamePosition: "prefix",
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelayMinutes: 5,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
operation: "/api/v1/security/auto-redact",
|
||||||
|
parameters: { mode: "automatic" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("toWirePolicy", () => {
|
||||||
|
it("sets trigger to null", () => {
|
||||||
|
expect(toWirePolicy(FULL_STATE).trigger).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets output.type to inline", () => {
|
||||||
|
expect(toWirePolicy(FULL_STATE).output.type).toBe("inline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("packs metadata into output.options", () => {
|
||||||
|
const wire = toWirePolicy(FULL_STATE);
|
||||||
|
const opts = wire.output.options;
|
||||||
|
expect(opts.categoryId).toBe("security");
|
||||||
|
expect(opts.sources).toEqual(["editor", "gdrive"]);
|
||||||
|
expect(opts.runOn).toBe("upload");
|
||||||
|
expect(opts.mode).toBe("new_version");
|
||||||
|
expect(opts.position).toBe("prefix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves steps at the top level", () => {
|
||||||
|
const wire = toWirePolicy(FULL_STATE);
|
||||||
|
expect(wire.steps).toEqual(FULL_STATE.steps);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fromWirePolicy → round-trip", () => {
|
||||||
|
it("recovers all fields after encode→decode", () => {
|
||||||
|
const wire = toWirePolicy(FULL_STATE);
|
||||||
|
const decoded = fromWirePolicy(wire);
|
||||||
|
expect(decoded.id).toBe(FULL_STATE.id);
|
||||||
|
expect(decoded.categoryId).toBe(FULL_STATE.categoryId);
|
||||||
|
expect(decoded.sources).toEqual(FULL_STATE.sources);
|
||||||
|
expect(decoded.scopeTypes).toEqual(FULL_STATE.scopeTypes);
|
||||||
|
expect(decoded.reviewerEmail).toBe(FULL_STATE.reviewerEmail);
|
||||||
|
expect(decoded.fieldValues).toEqual(FULL_STATE.fieldValues);
|
||||||
|
expect(decoded.runOn).toBe(FULL_STATE.runOn);
|
||||||
|
expect(decoded.outputMode).toBe(FULL_STATE.outputMode);
|
||||||
|
expect(decoded.outputName).toBe(FULL_STATE.outputName);
|
||||||
|
expect(decoded.outputNamePosition).toBe(FULL_STATE.outputNamePosition);
|
||||||
|
expect(decoded.maxRetries).toBe(FULL_STATE.maxRetries);
|
||||||
|
expect(decoded.retryDelayMinutes).toBe(FULL_STATE.retryDelayMinutes);
|
||||||
|
expect(decoded.steps).toEqual(FULL_STATE.steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults runOn to upload when missing", () => {
|
||||||
|
const wire = toWirePolicy(FULL_STATE);
|
||||||
|
delete (wire.output.options as Record<string, unknown>).runOn;
|
||||||
|
expect(fromWirePolicy(wire).runOn).toBe("upload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults outputMode to new_version when missing", () => {
|
||||||
|
const wire = toWirePolicy(FULL_STATE);
|
||||||
|
delete (wire.output.options as Record<string, unknown>).mode;
|
||||||
|
expect(fromWirePolicy(wire).outputMode).toBe("new_version");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves export runOn", () => {
|
||||||
|
const wire = toWirePolicy({ ...FULL_STATE, runOn: "export" });
|
||||||
|
expect(fromWirePolicy(wire).runOn).toBe("export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves new_file outputMode", () => {
|
||||||
|
const wire = toWirePolicy({ ...FULL_STATE, outputMode: "new_file" });
|
||||||
|
expect(fromWirePolicy(wire).outputMode).toBe("new_file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all three outputNamePosition values", () => {
|
||||||
|
for (const pos of ["prefix", "suffix", "auto-number"] as const) {
|
||||||
|
const wire = toWirePolicy({ ...FULL_STATE, outputNamePosition: pos });
|
||||||
|
expect(fromWirePolicy(wire).outputNamePosition).toBe(pos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty options gracefully", () => {
|
||||||
|
const decoded = fromWirePolicy({
|
||||||
|
id: "x",
|
||||||
|
name: "X",
|
||||||
|
enabled: false,
|
||||||
|
trigger: null,
|
||||||
|
steps: [],
|
||||||
|
output: { type: "inline", options: {} },
|
||||||
|
});
|
||||||
|
expect(decoded.categoryId).toBe("");
|
||||||
|
expect(decoded.sources).toEqual([]);
|
||||||
|
expect(decoded.runOn).toBe("upload");
|
||||||
|
expect(decoded.outputMode).toBe("new_version");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults fieldValues to empty object when missing", () => {
|
||||||
|
const wire = toWirePolicy(FULL_STATE);
|
||||||
|
delete (wire.output.options as Record<string, unknown>).fieldValues;
|
||||||
|
expect(fromWirePolicy(wire).fieldValues).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Bidirectional codec between the portal's frontend `PolicyDecodedState` and
|
||||||
|
* the backend `WirePolicy`. All policy-level metadata rides in
|
||||||
|
* `output.options`; `trigger` is always null (the editor fires runs on
|
||||||
|
* upload/export via `/run`). Mirrors the editor's `buildBackendPolicy` /
|
||||||
|
* `fromBackendPolicy` from `policyPipeline.ts`, minus the editor-only
|
||||||
|
* `automation` blob and toolRegistry coupling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PolicyDecodedState,
|
||||||
|
WireOutputOptions,
|
||||||
|
WirePolicy,
|
||||||
|
} from "@shared/policies/types";
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelayMinutes: 5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function toWirePolicy(state: PolicyDecodedState): WirePolicy {
|
||||||
|
const options: WireOutputOptions = {
|
||||||
|
runOn: state.runOn,
|
||||||
|
mode: state.outputMode,
|
||||||
|
name: state.outputName,
|
||||||
|
position: state.outputNamePosition,
|
||||||
|
maxRetries: state.maxRetries,
|
||||||
|
retryDelayMinutes: state.retryDelayMinutes,
|
||||||
|
categoryId: state.categoryId,
|
||||||
|
sources: state.sources,
|
||||||
|
scopeTypes: state.scopeTypes,
|
||||||
|
reviewerEmail: state.reviewerEmail,
|
||||||
|
fieldValues: state.fieldValues,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: state.id,
|
||||||
|
name: state.name,
|
||||||
|
owner: "",
|
||||||
|
enabled: state.enabled,
|
||||||
|
trigger: null,
|
||||||
|
steps: state.steps,
|
||||||
|
output: { type: "inline", options },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState {
|
||||||
|
const raw = policy.output?.options ?? {};
|
||||||
|
const str = (v: unknown, fallback = "") =>
|
||||||
|
typeof v === "string" ? v : fallback;
|
||||||
|
const num = (v: unknown, fallback: number) =>
|
||||||
|
typeof v === "number" ? v : fallback;
|
||||||
|
const position =
|
||||||
|
raw.position === "suffix"
|
||||||
|
? "suffix"
|
||||||
|
: raw.position === "auto-number"
|
||||||
|
? "auto-number"
|
||||||
|
: "prefix";
|
||||||
|
return {
|
||||||
|
id: policy.id,
|
||||||
|
name: policy.name,
|
||||||
|
enabled: policy.enabled,
|
||||||
|
categoryId: str(raw.categoryId),
|
||||||
|
sources: Array.isArray(raw.sources) ? (raw.sources as string[]) : [],
|
||||||
|
scopeTypes: Array.isArray(raw.scopeTypes)
|
||||||
|
? (raw.scopeTypes as string[])
|
||||||
|
: [],
|
||||||
|
reviewerEmail: str(raw.reviewerEmail),
|
||||||
|
fieldValues: raw.fieldValues ?? {},
|
||||||
|
runOn: raw.runOn === "export" ? "export" : "upload",
|
||||||
|
outputMode: raw.mode === "new_file" ? "new_file" : "new_version",
|
||||||
|
outputName: str(raw.name),
|
||||||
|
outputNamePosition: position,
|
||||||
|
maxRetries: num(raw.maxRetries, DEFAULTS.maxRetries),
|
||||||
|
retryDelayMinutes: num(raw.retryDelayMinutes, DEFAULTS.retryDelayMinutes),
|
||||||
|
steps: Array.isArray(policy.steps) ? policy.steps : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { runsToStats, runsToActivity } from "@shared/policies/runs";
|
||||||
|
import type { PolicyRunView } from "@shared/policies/types";
|
||||||
|
|
||||||
|
const MIN = 60000;
|
||||||
|
const HOUR = 3600000;
|
||||||
|
const DAY = 86400000;
|
||||||
|
// Use the actual current time so relative-time formatting in runs.ts is correct.
|
||||||
|
const NOW = Date.now();
|
||||||
|
|
||||||
|
const completed = (
|
||||||
|
id: string,
|
||||||
|
ts: number,
|
||||||
|
file = "doc.pdf",
|
||||||
|
): PolicyRunView => ({
|
||||||
|
runId: id,
|
||||||
|
policyId: "pol_1",
|
||||||
|
status: "COMPLETED",
|
||||||
|
currentStep: 2,
|
||||||
|
stepCount: 2,
|
||||||
|
error: null,
|
||||||
|
outputs: [{ fileId: "f", fileName: file }],
|
||||||
|
createdAt: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = (
|
||||||
|
id: string,
|
||||||
|
ts: number,
|
||||||
|
err = "Redaction failed",
|
||||||
|
): PolicyRunView => ({
|
||||||
|
runId: id,
|
||||||
|
policyId: "pol_1",
|
||||||
|
status: "FAILED",
|
||||||
|
currentStep: 1,
|
||||||
|
stepCount: 2,
|
||||||
|
error: err,
|
||||||
|
outputs: [],
|
||||||
|
createdAt: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const running = (id: string, ts: number, step = 1): PolicyRunView => ({
|
||||||
|
runId: id,
|
||||||
|
policyId: "pol_1",
|
||||||
|
status: "RUNNING",
|
||||||
|
currentStep: step,
|
||||||
|
stepCount: 2,
|
||||||
|
error: null,
|
||||||
|
outputs: [],
|
||||||
|
createdAt: ts,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runsToStats", () => {
|
||||||
|
it("counts only COMPLETED runs as enforced", () => {
|
||||||
|
const runs: PolicyRunView[] = [
|
||||||
|
completed("a", NOW - 10 * MIN),
|
||||||
|
completed("b", NOW - 20 * MIN),
|
||||||
|
failed("c", NOW - 30 * MIN),
|
||||||
|
running("d", NOW - 5 * MIN),
|
||||||
|
];
|
||||||
|
expect(runsToStats(runs).enforced).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns — for dataProcessed (not available from wire)", () => {
|
||||||
|
expect(runsToStats([completed("a", NOW - MIN)]).dataProcessed).toBe("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns — for activeFor when there are no runs", () => {
|
||||||
|
expect(runsToStats([]).activeFor).toBe("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes activeFor from the oldest run", () => {
|
||||||
|
const runs = [completed("a", NOW - 2 * DAY), completed("b", NOW - 5 * DAY)];
|
||||||
|
expect(runsToStats(runs).activeFor).toBe("5d");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runsToActivity", () => {
|
||||||
|
it("maps COMPLETED to enforced status", () => {
|
||||||
|
const [row] = runsToActivity([
|
||||||
|
completed("a", NOW - 10 * MIN, "invoice.pdf"),
|
||||||
|
]);
|
||||||
|
expect(row.status).toBe("enforced");
|
||||||
|
expect(row.doc).toBe("invoice.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps FAILED to flagged status with the error message", () => {
|
||||||
|
const [row] = runsToActivity([failed("b", NOW - HOUR, "Low confidence")]);
|
||||||
|
expect(row.status).toBe("flagged");
|
||||||
|
expect(row.action).toBe("Low confidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps RUNNING to processing status", () => {
|
||||||
|
const [row] = runsToActivity([running("c", NOW - MIN)]);
|
||||||
|
expect(row.status).toBe("processing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps CANCELLED to flagged status", () => {
|
||||||
|
const run: PolicyRunView = {
|
||||||
|
...failed("x", NOW - MIN, "Cancelled by user"),
|
||||||
|
status: "CANCELLED",
|
||||||
|
};
|
||||||
|
expect(runsToActivity([run])[0].status).toBe("flagged");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps PENDING to processing status", () => {
|
||||||
|
const run: PolicyRunView = {
|
||||||
|
...running("x", NOW - MIN),
|
||||||
|
status: "PENDING",
|
||||||
|
};
|
||||||
|
expect(runsToActivity([run])[0].status).toBe("processing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps WAITING_FOR_INPUT to processing status", () => {
|
||||||
|
const run: PolicyRunView = {
|
||||||
|
...running("x", NOW - MIN),
|
||||||
|
status: "WAITING_FOR_INPUT",
|
||||||
|
};
|
||||||
|
expect(runsToActivity([run])[0].status).toBe("processing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'Enforcement failed' when FAILED run has no error message", () => {
|
||||||
|
const run: PolicyRunView = { ...failed("x", NOW - MIN), error: null };
|
||||||
|
expect(runsToActivity([run])[0].action).toBe("Enforcement failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows step progress when currentStep and stepCount are set", () => {
|
||||||
|
const [row] = runsToActivity([running("c", NOW - MIN, 1)]);
|
||||||
|
expect(row.action).toContain("step 1/2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Policy run when outputs is empty", () => {
|
||||||
|
const noOutput: PolicyRunView = { ...running("x", NOW - MIN), outputs: [] };
|
||||||
|
expect(runsToActivity([noOutput])[0].doc).toBe("Policy run");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats recent timestamps as Nm ago", () => {
|
||||||
|
const [row] = runsToActivity([completed("a", NOW - 5 * MIN)]);
|
||||||
|
expect(row.time).toBe("5m ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats hour-range timestamps as Nh ago", () => {
|
||||||
|
const [row] = runsToActivity([completed("a", NOW - 2 * HOUR)]);
|
||||||
|
expect(row.time).toBe("2h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats day-range timestamps as Nd ago", () => {
|
||||||
|
const [row] = runsToActivity([completed("a", NOW - 3 * DAY)]);
|
||||||
|
expect(row.time).toBe("3d ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Derives display data from raw backend `PolicyRunView` records. The backend
|
||||||
|
* `GET /api/v1/policies/runs` endpoint returns these; both the portal and
|
||||||
|
* (eventually) the editor read this same derivation rather than duplicating it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PolicyActivityItem,
|
||||||
|
PolicyRunView,
|
||||||
|
PolicyStats,
|
||||||
|
} from "@shared/policies/types";
|
||||||
|
|
||||||
|
function relativeTime(epochMs: number): string {
|
||||||
|
if (!epochMs) return "Just now";
|
||||||
|
const mins = Math.floor((Date.now() - epochMs) / 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`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationSince(epochMs: number): string {
|
||||||
|
if (!epochMs) return "—";
|
||||||
|
const ms = Date.now() - epochMs;
|
||||||
|
const days = Math.floor(ms / 86400000);
|
||||||
|
if (days >= 1) return `${days}d`;
|
||||||
|
const hrs = Math.floor(ms / 3600000);
|
||||||
|
return hrs >= 1 ? `${hrs}h` : "Today";
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityStatus(run: PolicyRunView): PolicyActivityItem["status"] {
|
||||||
|
if (run.status === "COMPLETED") return "enforced";
|
||||||
|
if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged";
|
||||||
|
return "processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityAction(run: PolicyRunView): string {
|
||||||
|
const s = activityStatus(run);
|
||||||
|
if (s === "enforced") return "Enforced";
|
||||||
|
if (s === "flagged") return run.error ?? "Enforcement failed";
|
||||||
|
const { currentStep, stepCount } = run;
|
||||||
|
return currentStep && stepCount
|
||||||
|
? `Enforcing… · step ${currentStep}/${stepCount}`
|
||||||
|
: "Enforcing…";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runsToActivity(runs: PolicyRunView[]): PolicyActivityItem[] {
|
||||||
|
return runs.map((run) => ({
|
||||||
|
doc: run.outputs[0]?.fileName ?? "Policy run",
|
||||||
|
action: activityAction(run),
|
||||||
|
time: relativeTime(run.createdAt),
|
||||||
|
status: activityStatus(run),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runsToStats(runs: PolicyRunView[]): PolicyStats {
|
||||||
|
const completed = runs.filter((r) => r.status === "COMPLETED");
|
||||||
|
const oldest = runs.reduce((min, r) => Math.min(min, r.createdAt), Infinity);
|
||||||
|
return {
|
||||||
|
enforced: completed.length,
|
||||||
|
dataProcessed: "—",
|
||||||
|
activeFor: isFinite(oldest) ? durationSince(oldest) : "—",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Wire types for the Stirling policy API (`/api/v1/policies`), the decoded
|
||||||
|
* frontend shape, and the display types used to render stats and activity.
|
||||||
|
*
|
||||||
|
* The backend stores all portal-level metadata (categoryId, sources, scope,
|
||||||
|
* reviewer, fieldValues, runOn, output settings) inside `output.options` — the
|
||||||
|
* same "options bag" the editor uses. `trigger` is always null for
|
||||||
|
* portal/editor-authored policies; the editor fires runs on upload/export via
|
||||||
|
* `/run`, so there is no server-side trigger.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Wire types (match Policy.java / PipelineStep.java / PolicyRunView.java) ──
|
||||||
|
|
||||||
|
export interface WirePipelineStep {
|
||||||
|
operation: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
fileParameters?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WireOutputOptions {
|
||||||
|
runOn: "upload" | "export";
|
||||||
|
mode: "new_file" | "new_version";
|
||||||
|
name: string;
|
||||||
|
position: "prefix" | "suffix" | "auto-number";
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelayMinutes?: number;
|
||||||
|
categoryId: string;
|
||||||
|
sources: string[];
|
||||||
|
scopeTypes: string[];
|
||||||
|
reviewerEmail: string;
|
||||||
|
fieldValues: Record<string, boolean | string | string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WireOutputSpec {
|
||||||
|
type: "inline";
|
||||||
|
options: Partial<WireOutputOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WirePolicy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
trigger: null;
|
||||||
|
steps: WirePipelineStep[];
|
||||||
|
output: WireOutputSpec;
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run view (mirrors PolicyRunView.java) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export type PolicyRunStatus =
|
||||||
|
| "PENDING"
|
||||||
|
| "RUNNING"
|
||||||
|
| "WAITING_FOR_INPUT"
|
||||||
|
| "COMPLETED"
|
||||||
|
| "FAILED"
|
||||||
|
| "CANCELLED";
|
||||||
|
|
||||||
|
export interface PolicyRunView {
|
||||||
|
runId: string;
|
||||||
|
policyId: string | null;
|
||||||
|
status: PolicyRunStatus;
|
||||||
|
currentStep: number;
|
||||||
|
stepCount: number;
|
||||||
|
error: string | null;
|
||||||
|
errorCode?: string | null;
|
||||||
|
errorSubscribed?: boolean | null;
|
||||||
|
outputs: { fileId: string; fileName: string }[];
|
||||||
|
/** Creation timestamp in epoch milliseconds. */
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frontend decoded shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Policy settings unpacked from the wire record's `output.options` bag. */
|
||||||
|
export interface PolicyDecodedState {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
categoryId: string;
|
||||||
|
sources: string[];
|
||||||
|
scopeTypes: string[];
|
||||||
|
reviewerEmail: string;
|
||||||
|
fieldValues: Record<string, boolean | string | string[]>;
|
||||||
|
runOn: "upload" | "export";
|
||||||
|
outputMode: "new_file" | "new_version";
|
||||||
|
outputName: string;
|
||||||
|
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
||||||
|
maxRetries: number;
|
||||||
|
retryDelayMinutes: number;
|
||||||
|
steps: WirePipelineStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display types (returned by runs.ts derivations) ───────────────────────────
|
||||||
|
|
||||||
|
export interface PolicyStats {
|
||||||
|
/** Completed enforcement runs. */
|
||||||
|
enforced: number;
|
||||||
|
/** Human-formatted total data processed (e.g. "2.3 GB"), or "—" when unknown. */
|
||||||
|
dataProcessed: string;
|
||||||
|
/** Human-formatted duration since first run (e.g. "34d"), or "—". */
|
||||||
|
activeFor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyActivityItem {
|
||||||
|
doc: string;
|
||||||
|
action: string;
|
||||||
|
/** Relative timestamp, e.g. "2h ago". */
|
||||||
|
time: string;
|
||||||
|
status: "enforced" | "flagged" | "processing";
|
||||||
|
}
|
||||||
+11
-11
@@ -1,24 +1,24 @@
|
|||||||
# Prints one free TCP port per preferred port given as an argument.
|
# Prints one free TCP port per preferred port given as an argument.
|
||||||
#
|
#
|
||||||
# For each element of -Preferred, emits that port if it's free; otherwise
|
# For each element of -Preferred, emits that port if it's free; otherwise
|
||||||
# emits a random free port in 20000-49999. Probes by attempting to bind a
|
# emits a random free port in 20000-49999. Uses Get-NetTCPConnection to read
|
||||||
# TcpListener on loopback. Tracks picks within this run so outputs are
|
# the OS socket table directly — more reliable than TcpListener binding on
|
||||||
# guaranteed distinct from each other.
|
# Windows where SO_REUSEADDR can cause false "free" results. Tracks picks
|
||||||
|
# within this run so outputs are guaranteed distinct from each other.
|
||||||
param([Parameter(ValueFromRemainingArguments = $true)][int[]]$Preferred)
|
param([Parameter(ValueFromRemainingArguments = $true)][int[]]$Preferred)
|
||||||
|
|
||||||
$script:picked = @()
|
$script:picked = @()
|
||||||
|
|
||||||
|
# Build a set of ports currently in LISTEN or ESTABLISHED state once upfront.
|
||||||
|
$usedPorts = [System.Collections.Generic.HashSet[int]]::new()
|
||||||
|
Get-NetTCPConnection -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.State -in 'Listen', 'Established' } |
|
||||||
|
ForEach-Object { $null = $usedPorts.Add($_.LocalPort) }
|
||||||
|
|
||||||
function Test-PortFree {
|
function Test-PortFree {
|
||||||
param([int]$Port)
|
param([int]$Port)
|
||||||
if ($script:picked -contains $Port) { return $false }
|
if ($script:picked -contains $Port) { return $false }
|
||||||
try {
|
return -not $usedPorts.Contains($Port)
|
||||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
|
|
||||||
$listener.Start()
|
|
||||||
$listener.Stop()
|
|
||||||
return $true
|
|
||||||
} catch {
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-RandomFreePort {
|
function Get-RandomFreePort {
|
||||||
|
|||||||
Reference in New Issue
Block a user