mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Portal policies (#6852)
This commit is contained in:
@@ -95,6 +95,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
@@ -117,6 +118,32 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
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"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
|
||||
@@ -212,8 +212,12 @@ export function usePolicyAutoRun(): void {
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
// Only auto-run on upload when the policy is set to run on upload
|
||||
// (export-triggered policies enforce at export time instead).
|
||||
// Only enforce in the editor when the policy includes "editor" as a source.
|
||||
// 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",
|
||||
);
|
||||
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).
|
||||
const outputMode = policies[run.categoryId]?.outputMode ?? "new_version";
|
||||
const outputName = policies[run.categoryId]?.outputName ?? "";
|
||||
const outputNamePosition = policies[run.categoryId]?.outputNamePosition;
|
||||
const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId);
|
||||
void importOutputs(run, {
|
||||
addFiles,
|
||||
@@ -282,6 +287,7 @@ export function usePolicyAutoRun(): void {
|
||||
bumpRevision,
|
||||
outputMode,
|
||||
outputName,
|
||||
outputNamePosition,
|
||||
parentStub,
|
||||
}).finally(() => importing.current.delete(run.runId));
|
||||
}
|
||||
@@ -314,9 +320,11 @@ interface ImportContext {
|
||||
bumpRevision: () => void;
|
||||
/** "new_file" adds the output as a separate file; "new_version" versions the input. */
|
||||
outputMode: "new_file" | "new_version";
|
||||
/** Rename rule. Empty → keep the input's filename; set → use the policy's
|
||||
* renamed output (applied server-side per the name-position setting). */
|
||||
/** Rename rule. Empty → keep the input's filename. */
|
||||
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. */
|
||||
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
|
||||
* 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(
|
||||
policies: PoliciesByCategory,
|
||||
): Promise<void> {
|
||||
@@ -406,7 +428,11 @@ async function importOutputs(
|
||||
// rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would
|
||||
// otherwise rename every output.
|
||||
const targetName = ctx.outputName
|
||||
? undefined // use the run's per-output (renamed) name below
|
||||
? applyOutputName(
|
||||
run.fileName,
|
||||
ctx.outputName,
|
||||
ctx.outputNamePosition ?? "suffix",
|
||||
)
|
||||
: run.fileName;
|
||||
const settled = await Promise.allSettled(
|
||||
pending.map(async (out) => {
|
||||
|
||||
@@ -134,6 +134,7 @@ export function usePolicies() {
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
outputMode: result.folder.outputMode,
|
||||
outputName: result.folder.outputName,
|
||||
outputNamePosition: result.folder.outputNamePosition,
|
||||
runOn: result.folder.runOn,
|
||||
});
|
||||
},
|
||||
@@ -170,6 +171,7 @@ export function usePolicies() {
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
outputMode: result.folder.outputMode,
|
||||
outputName: result.folder.outputName,
|
||||
outputNamePosition: result.folder.outputNamePosition,
|
||||
runOn: result.folder.runOn,
|
||||
});
|
||||
},
|
||||
@@ -234,6 +236,7 @@ export function usePolicies() {
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
outputMode: result.folder.outputMode,
|
||||
outputName: result.folder.outputName,
|
||||
outputNamePosition: result.folder.outputNamePosition,
|
||||
runOn: result.folder.runOn,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export function decodedToState(
|
||||
fieldValues: decoded.fieldValues,
|
||||
outputMode: decoded.folder.outputMode,
|
||||
outputName: decoded.folder.outputName,
|
||||
outputNamePosition: decoded.folder.outputNamePosition,
|
||||
runOn: decoded.folder.runOn,
|
||||
folderId: localFolderId,
|
||||
backendId: decoded.id,
|
||||
|
||||
@@ -70,6 +70,7 @@ function activeExportPolicies(): ExportPolicy[] {
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
(s.sources.length === 0 || s.sources.includes("editor")) &&
|
||||
s.runOn === "export",
|
||||
)
|
||||
.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
|
||||
* name-position setting. */
|
||||
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". */
|
||||
runOn?: "upload" | "export";
|
||||
/**
|
||||
|
||||
@@ -379,6 +379,9 @@ label = "Read mode"
|
||||
consume = "Consume: process each file once"
|
||||
snapshot = "Snapshot: re-read the folder every run"
|
||||
|
||||
[sources.types.editor]
|
||||
label = "Editor"
|
||||
|
||||
[sources.types.unknown]
|
||||
label = "Source"
|
||||
|
||||
@@ -696,6 +699,11 @@ description = "{{file}} is signed and ready to transfer. It activates one instan
|
||||
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."
|
||||
|
||||
[policies.offline]
|
||||
title = "Backend unavailable"
|
||||
description = "Your policies are saved and will appear once the connection is restored."
|
||||
retry = "Retry"
|
||||
|
||||
[policies.status]
|
||||
active = "Active"
|
||||
paused = "Paused"
|
||||
@@ -724,15 +732,17 @@ description = "Across active policies"
|
||||
[policies.card]
|
||||
comingSoon = "Coming soon"
|
||||
notSetUp = "Not set up"
|
||||
setUp = "Set up →"
|
||||
|
||||
[policies.detail]
|
||||
title = "{{category}} policy"
|
||||
meta = "Runs on {{event}} · output {{output}}"
|
||||
outputAsNewFile = "as a new file"
|
||||
outputAsNewVersion = "as a new version"
|
||||
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"
|
||||
|
||||
[policies.detail.actions]
|
||||
@@ -746,10 +756,6 @@ editSettings = "Edit settings"
|
||||
title = "No activity yet"
|
||||
description = "Documents will appear here once this policy runs."
|
||||
|
||||
[policies.detail.scoped]
|
||||
title = "Scoped"
|
||||
description = "Limited to: {{types}}"
|
||||
|
||||
[policies.wizard.title]
|
||||
edit = "Edit {{category}} policy"
|
||||
setUp = "Set up {{category}} policy"
|
||||
@@ -778,6 +784,9 @@ heading = "Settings"
|
||||
|
||||
[policies.wizard.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]
|
||||
heading = "Document types"
|
||||
@@ -809,9 +818,10 @@ suffix = "Suffix"
|
||||
autoNumber = "Auto-number"
|
||||
placeholder = "Text to add (optional)"
|
||||
|
||||
[policies.wizard.output.reviewerEmail]
|
||||
label = "Reviewer email"
|
||||
helper = "Low-confidence enforcements are routed here for review."
|
||||
[policies.wizard.output.retries]
|
||||
heading = "Retries"
|
||||
maxLabel = "Max retries"
|
||||
delayLabel = "Retry delay (min)"
|
||||
|
||||
[users.summary]
|
||||
members = "Members"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
* 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.
|
||||
*/
|
||||
import { getStoredToken } from "@shared/auth";
|
||||
import { clearStoredToken, getStoredToken } from "@shared/auth";
|
||||
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
|
||||
@@ -156,6 +156,12 @@ async function localJson<T>(
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* one calls the REAL Stirling policy API base `/api/v1/policies` so it is
|
||||
* genuinely plug-and-play: drop MSW and these exact calls hit the live backend
|
||||
* (PolicyController). The list response is the portal's catalogue shape; the
|
||||
* single-policy / create / delete / run calls match the backend records.
|
||||
* The portal calls the real Stirling policy API (`/api/v1/policies`). MSW
|
||||
* intercepts these calls in dev/Storybook; dropping MSW is enough to hit the
|
||||
* live backend — no call-site changes needed.
|
||||
*
|
||||
* `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 {
|
||||
CatalogueEntry,
|
||||
DecoratedPolicy,
|
||||
InputSpec,
|
||||
OutputSpec,
|
||||
PipelineStep,
|
||||
PoliciesResponse,
|
||||
PoliciesSummary,
|
||||
Policy,
|
||||
PolicyActivityItem,
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyDecodedState,
|
||||
PolicyField,
|
||||
PolicyFieldType,
|
||||
PolicyRowStatus,
|
||||
PolicyRunView,
|
||||
PolicySetupResult,
|
||||
PolicySource,
|
||||
PolicyState,
|
||||
PolicyStats,
|
||||
PolicyActivityItem,
|
||||
PolicyStatus,
|
||||
TriggerConfig,
|
||||
WirePolicy,
|
||||
WireOutputOptions,
|
||||
WireOutputSpec,
|
||||
} from "@portal/mocks/policies";
|
||||
export {
|
||||
ENDPOINT_LABELS,
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
POLICY_DOC_TYPES,
|
||||
POLICY_SOURCES,
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
} 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> {
|
||||
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. */
|
||||
export async function fetchPolicy(id: string): Promise<Policy> {
|
||||
return apiClient.local.json<Policy>(
|
||||
export async function fetchPolicy(id: string): Promise<WirePolicy> {
|
||||
return apiClient.local.json<WirePolicy>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies — create (blank id) or update (matched id). The backend
|
||||
* assigns owner + team server-side and returns the stored policy with its id.
|
||||
* POST /api/v1/policies — create (blank id) or update (matched id). The
|
||||
* backend stamps owner + teamId server-side and returns the stored record.
|
||||
*/
|
||||
export async function savePolicy(policy: Policy): Promise<Policy> {
|
||||
return apiClient.local.json<Policy>("/api/v1/policies", {
|
||||
export async function savePolicy(wire: WirePolicy): Promise<WirePolicy> {
|
||||
return apiClient.local.json<WirePolicy>("/api/v1/policies", {
|
||||
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> {
|
||||
await apiClient.local.json<void>(
|
||||
`/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. */
|
||||
export interface PolicyRunResponse {
|
||||
status: boolean;
|
||||
/** The run id (poll GET /api/v1/policies/run/{id} for status). */
|
||||
fileId: string | null;
|
||||
message: string | null;
|
||||
// ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ────────
|
||||
|
||||
const DEFAULT_RETRIES = 3;
|
||||
const DEFAULT_RETRY_DELAY = 5;
|
||||
|
||||
// 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
|
||||
* is multipart (the documents to process); the portal has no files to attach,
|
||||
* so this triggers the policy on whatever the backend has queued and returns a
|
||||
* run id. Runs regardless of the policy's enabled flag.
|
||||
* POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The
|
||||
* real endpoint is multipart; the portal sends no files, relying on whatever
|
||||
* the backend has queued for this policy.
|
||||
*/
|
||||
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
||||
return apiClient.local.json<PolicyRunResponse>(
|
||||
export async function runPolicy(id: string): Promise<{ runId: string }> {
|
||||
return apiClient.local.json<{ runId: string }>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -9,17 +9,13 @@ interface PolicyCategoryCardProps {
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon;
|
||||
const status = policy?.state.status;
|
||||
const enforces = config.rules.join(" · ");
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -42,22 +38,39 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<header className="portal-policies__card-head">
|
||||
<span
|
||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
<div className="portal-policies__card-titles">
|
||||
|
||||
<div className="portal-policies__card-identity">
|
||||
<h2 className="portal-policies__card-title">{category.label}</h2>
|
||||
<span className="portal-policies__card-blurb">{category.desc}</span>
|
||||
{enforces && (
|
||||
<span className="portal-policies__card-enforces">{enforces}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{comingSoon ? (
|
||||
<Chip tone="neutral" size="sm">
|
||||
{t("policies.card.comingSoon")}
|
||||
</Chip>
|
||||
) : 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
|
||||
tone={status === "paused" ? "warning" : "success"}
|
||||
size="sm"
|
||||
@@ -67,46 +80,12 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
? t("policies.status.paused")
|
||||
: t("policies.status.active")}
|
||||
</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">
|
||||
<div className="portal-policies__card-rules">
|
||||
{config.rules.slice(0, 3).map((rule) => (
|
||||
<Chip key={rule} tone="neutral" size="sm">
|
||||
{rule}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
{!comingSoon && (
|
||||
<span className="portal-policies__card-cta">
|
||||
{t("policies.card.setUp")}
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const meta: Meta<typeof PolicyDetailPanel> = {
|
||||
onRun: () => {},
|
||||
onTogglePause: () => {},
|
||||
onDelete: () => {},
|
||||
onRetry: () => {},
|
||||
},
|
||||
};
|
||||
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 {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
EmptyState,
|
||||
Modal,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
} from "@shared/components";
|
||||
import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import {
|
||||
humanizeEndpoint,
|
||||
type DecoratedPolicy,
|
||||
type PolicyActivityItem,
|
||||
} from "@portal/api/policies";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicyDetailPanelProps {
|
||||
/** The configured policy being viewed, or null when closed. */
|
||||
policy: DecoratedPolicy | null;
|
||||
/** Whether a lifecycle action (run/pause/delete) is in flight. */
|
||||
busy?: boolean;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onRun: () => void;
|
||||
onRun?: () => void;
|
||||
onTogglePause: () => void;
|
||||
onDelete: () => void;
|
||||
onRetry?: (item: PolicyActivityItem) => void;
|
||||
}
|
||||
|
||||
const ACTIVITY_TONE = {
|
||||
enforced: "success",
|
||||
flagged: "warning",
|
||||
processing: "info",
|
||||
} as const;
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
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({
|
||||
policy,
|
||||
busy = false,
|
||||
@@ -45,31 +105,36 @@ export function PolicyDetailPanel({
|
||||
onRun,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
onRetry,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!policy) return null;
|
||||
const { category, config, state, steps, stats, activity } = policy;
|
||||
const isPaused = state.status === "paused";
|
||||
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 (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={
|
||||
<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}
|
||||
title={category.label}
|
||||
footer={
|
||||
<div className="portal-policies__detail-foot">
|
||||
{canDelete && (
|
||||
@@ -84,6 +149,7 @@ export function PolicyDetailPanel({
|
||||
{t("policies.detail.actions.delete")}
|
||||
</Button>
|
||||
)}
|
||||
{onRun && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -93,6 +159,7 @@ export function PolicyDetailPanel({
|
||||
>
|
||||
{t("policies.detail.actions.runNow")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -109,57 +176,66 @@ export function PolicyDetailPanel({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Status + trigger strip */}
|
||||
<div className="portal-policies__detail-status">
|
||||
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
|
||||
{isPaused ? t("policies.status.paused") : t("policies.status.active")}
|
||||
</StatusBadge>
|
||||
<span className="portal-policies__detail-meta">
|
||||
{t("policies.detail.meta", {
|
||||
event: state.runOn ?? "upload",
|
||||
output:
|
||||
state.outputMode === "new_file"
|
||||
? t("policies.detail.outputAsNewFile")
|
||||
: t("policies.detail.outputAsNewVersion"),
|
||||
})}
|
||||
{hasEditorSource && (
|
||||
<>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">{trigger}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.detail.enforces")}
|
||||
</h3>
|
||||
<Card padding="default">
|
||||
{enforceItems.length > 0 ? (
|
||||
<div className="portal-policies__enforce-flow">
|
||||
{enforceItems.map((op, i) => (
|
||||
<span key={op} className="portal-policies__enforce-item">
|
||||
{i > 0 && (
|
||||
<span className="portal-policies__enforce-arrow" aria-hidden>
|
||||
→
|
||||
{/* Sources */}
|
||||
{state.sources.length > 0 && (
|
||||
<div className="portal-policies__detail-inline">
|
||||
<span className="portal-policies__detail-inline-label">
|
||||
{t("policies.detail.sources")}
|
||||
</span>
|
||||
)}
|
||||
<Chip tone="blue" size="sm">
|
||||
{humanizeEndpoint(op)}
|
||||
</Chip>
|
||||
<span className="portal-policies__detail-inline-value">
|
||||
{state.sources.map(sourceLabel).join(" · ")}
|
||||
</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">
|
||||
{t("policies.detail.recentActivity")}
|
||||
</h3>
|
||||
|
||||
{activity.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activity.map((item, i) => (
|
||||
@@ -168,20 +244,46 @@ export function PolicyDetailPanel({
|
||||
className="portal-policies__activity-row"
|
||||
>
|
||||
<span
|
||||
className={`portal-policies__activity-dot portal-policies__activity-dot--${ACTIVITY_TONE[item.status]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
className={`portal-policies__activity-icon portal-policies__activity-icon--${
|
||||
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-doc">
|
||||
{item.doc}
|
||||
</span>
|
||||
<span className="portal-policies__activity-action">
|
||||
{item.action}
|
||||
{item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} />
|
||||
) : (
|
||||
item.action
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-policies__activity-time">
|
||||
{item.time}
|
||||
</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>
|
||||
))}
|
||||
</Card>
|
||||
@@ -209,16 +311,6 @@ export function PolicyDetailPanel({
|
||||
value={stats.activeFor}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,16 @@ import {
|
||||
} from "@shared/components";
|
||||
import {
|
||||
POLICY_DOC_TYPES,
|
||||
POLICY_SOURCES,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
@@ -59,15 +61,27 @@ function resolveFieldValues(
|
||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||
* 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[] {
|
||||
const source = entry.policy?.steps?.length
|
||||
? entry.policy.steps
|
||||
: entry.config.defaultOperations;
|
||||
return source.map((s) => ({
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
// Always use defaultOperations as the canonical list so tools added after a
|
||||
// policy was first saved still appear when editing.
|
||||
return entry.config.defaultOperations.map((s) => {
|
||||
const saved = savedByOp.get(s.operation);
|
||||
return {
|
||||
operation: s.operation,
|
||||
enabled: true,
|
||||
parameters: s.parameters,
|
||||
}));
|
||||
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),
|
||||
);
|
||||
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(
|
||||
(policy?.state.scopeTypes.length ?? 0) > 0,
|
||||
);
|
||||
const [scopeTypes, setScopeTypes] = useState<string[]>(
|
||||
policy?.state.scopeTypes ?? [],
|
||||
);
|
||||
const [reviewerEmail, setReviewerEmail] = useState(
|
||||
policy?.state.reviewerEmail ?? "you@acme.com",
|
||||
);
|
||||
// TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]).
|
||||
// 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">(
|
||||
policy?.state.outputMode ?? "new_version",
|
||||
);
|
||||
const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
|
||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||
"prefix" | "suffix" | "auto-number"
|
||||
>("suffix");
|
||||
>(policy?.state.outputNamePosition ?? "suffix");
|
||||
const [runOn, setRunOn] = useState<"upload" | "export">(
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
@@ -180,6 +217,8 @@ function PolicySetupWizardBody({
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
runOn,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
steps,
|
||||
});
|
||||
} catch {
|
||||
@@ -197,10 +236,7 @@ function PolicySetupWizardBody({
|
||||
width="lg"
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<span
|
||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{isEdit
|
||||
@@ -272,9 +308,7 @@ function PolicySetupWizardBody({
|
||||
<span className="portal-policies__tool-name">
|
||||
{humanizeEndpoint(tl.operation)}
|
||||
</span>
|
||||
<code className="portal-policies__tool-endpoint">
|
||||
{tl.operation}
|
||||
</code>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
@@ -315,7 +349,18 @@ function PolicySetupWizardBody({
|
||||
{t("policies.wizard.sources.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__sources">
|
||||
{POLICY_SOURCES.map((src) => (
|
||||
{sourcesAsync.loading && !sourcesAsync.data ? (
|
||||
<p className="portal-policies__sources-loading">
|
||||
{t("policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : availableSources.length === 1 ? (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("policies.wizard.sources.emptyTitle")}
|
||||
description={t("policies.wizard.sources.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
availableSources.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
@@ -328,18 +373,19 @@ function PolicySetupWizardBody({
|
||||
onClick={() => toggleSource(src.id)}
|
||||
>
|
||||
<span className="portal-policies__source-icon" aria-hidden>
|
||||
{policyIcon(src.icon)}
|
||||
{sourceTypeMeta(src.type).icon}
|
||||
</span>
|
||||
<span className="portal-policies__source-text">
|
||||
<span className="portal-policies__source-label">
|
||||
{src.label}
|
||||
{src.name}
|
||||
</span>
|
||||
<span className="portal-policies__source-desc">
|
||||
{src.desc}
|
||||
{src.type}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
@@ -392,6 +438,8 @@ function PolicySetupWizardBody({
|
||||
{t("policies.wizard.output.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__fields">
|
||||
{sources.includes("editor") && (
|
||||
<>
|
||||
<FormField
|
||||
label={t("policies.wizard.output.runOn.label")}
|
||||
helperText={t("policies.wizard.output.runOn.helper")}
|
||||
@@ -441,7 +489,9 @@ function PolicySetupWizardBody({
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("policies.wizard.output.filenameRule.label")}>
|
||||
<FormField
|
||||
label={t("policies.wizard.output.filenameRule.label")}
|
||||
>
|
||||
<div className="portal-policies__name-row">
|
||||
<Select
|
||||
inputSize="sm"
|
||||
@@ -454,11 +504,15 @@ function PolicySetupWizardBody({
|
||||
options={[
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("policies.wizard.output.filenameRule.prefix"),
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.prefix",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("policies.wizard.output.filenameRule.suffix"),
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.suffix",
|
||||
),
|
||||
},
|
||||
...(outputMode === "new_file"
|
||||
? [
|
||||
@@ -484,15 +538,30 @@ function PolicySetupWizardBody({
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("policies.wizard.output.reviewerEmail.label")}
|
||||
helperText={t("policies.wizard.output.reviewerEmail.helper")}
|
||||
>
|
||||
</>
|
||||
)}
|
||||
{/* 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
|
||||
inputSize="sm"
|
||||
type="email"
|
||||
value={reviewerEmail}
|
||||
onChange={(e) => setReviewerEmail(e.target.value)}
|
||||
type="number"
|
||||
value={String(maxRetries)}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -3,38 +3,53 @@
|
||||
* policy built straight from the catalogue + seed data, so stories render the
|
||||
* 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 {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
seedRuntime,
|
||||
seedPolicies,
|
||||
seedPolicyRuns,
|
||||
type DecoratedPolicy,
|
||||
type PolicyState,
|
||||
} from "@portal/mocks/policies";
|
||||
|
||||
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 {
|
||||
const category = POLICY_CATEGORIES.find((c) => c.id === 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 {
|
||||
category,
|
||||
config,
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
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,
|
||||
state,
|
||||
steps: decoded.steps,
|
||||
stats: runsToStats(policyRuns),
|
||||
activity: runsToActivity(policyRuns),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SourceTypeMeta {
|
||||
|
||||
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
|
||||
folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" },
|
||||
editor: { labelKey: "sources.types.editor.label", icon: "✏", tone: "green" },
|
||||
};
|
||||
|
||||
const UNKNOWN_TYPE_META: SourceTypeMeta = {
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
seedPolicies,
|
||||
seedRuntime,
|
||||
emptyRuntime,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PoliciesResponse,
|
||||
type PoliciesSummary,
|
||||
type Policy,
|
||||
type PolicyRowStatus,
|
||||
type PolicyRuntime,
|
||||
type PolicyState,
|
||||
seedPolicyRuns,
|
||||
type WirePolicy,
|
||||
} from "@portal/mocks/policies";
|
||||
import type { PolicyRunView } from "@shared/policies/types";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* backend (drop MSW and the same calls hit Stirling). These handlers mutate an
|
||||
* in-memory store, so create/delete/run behave like a real backend within a
|
||||
* session (see the notifications handler for the same stateful pattern).
|
||||
* backend (drop MSW and the same calls hit Stirling).
|
||||
*
|
||||
* 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: Policy[] = seedPolicies();
|
||||
/** Runtime extras the wire record doesn't carry (scope, stats, activity). */
|
||||
let runtime: Record<string, PolicyRuntime> = seedRuntime();
|
||||
let store: WirePolicy[] = seedPolicies();
|
||||
let runs: PolicyRunView[] = seedPolicyRuns();
|
||||
|
||||
export function resetPoliciesStore(
|
||||
seed?: Policy[],
|
||||
seedRt?: Record<string, PolicyRuntime>,
|
||||
seed?: WirePolicy[],
|
||||
seedRuns?: PolicyRunView[],
|
||||
): void {
|
||||
store = seed ? [...seed] : seedPolicies();
|
||||
runtime = seedRt ? { ...seedRt } : seedRuntime();
|
||||
runs = seedRuns ? [...seedRuns] : seedPolicyRuns();
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
@@ -42,78 +38,21 @@ function nextId(categoryId: string): string {
|
||||
return `pol_${categoryId}_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
/** Derive the display status from the wire `enabled` flag. */
|
||||
function rowStatus(policy: Policy): PolicyRowStatus {
|
||||
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 };
|
||||
function categoryId(wire: WirePolicy): string {
|
||||
return (wire.output?.options?.categoryId as string | undefined) ?? "";
|
||||
}
|
||||
|
||||
export const policiesHandlers = [
|
||||
// List — the catalogue (categories + configs + configured policies).
|
||||
http.get("/api/v1/policies", async () => {
|
||||
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 }) => {
|
||||
await delay(120);
|
||||
const policy = store.find((p) => p.id === params.id);
|
||||
@@ -121,17 +60,17 @@ export const policiesHandlers = [
|
||||
return HttpResponse.json(policy);
|
||||
}),
|
||||
|
||||
// Create or update — a blank id is assigned (create) or matched (update).
|
||||
// One policy per category: a create for a category that already has one
|
||||
// replaces it, matching the editor's "one policy per category, ever".
|
||||
// Create or update — one policy per category: a create for a category that
|
||||
// already has one replaces it, matching the editor's contract.
|
||||
http.post("/api/v1/policies", async ({ request }) => {
|
||||
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
|
||||
? store.find((p) => p.id === incoming.id)
|
||||
: store.find((p) => p.categoryId === incoming.categoryId);
|
||||
const id = existing?.id ?? nextId(incoming.categoryId);
|
||||
const saved: Policy = {
|
||||
: store.find((p) => categoryId(p) === catId);
|
||||
const id = existing?.id ?? nextId(catId);
|
||||
const saved: WirePolicy = {
|
||||
...incoming,
|
||||
id,
|
||||
owner: existing?.owner ?? "you@acme.com",
|
||||
@@ -139,45 +78,16 @@ export const policiesHandlers = [
|
||||
store = existing
|
||||
? store.map((p) => (p.id === id ? saved : p))
|
||||
: [...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);
|
||||
}),
|
||||
|
||||
// Delete a stored policy by id.
|
||||
http.delete("/api/v1/policies/:id", async ({ params }) => {
|
||||
await delay(120);
|
||||
const id = String(params.id);
|
||||
const existed = store.some((p) => p.id === id);
|
||||
if (!existed) return new HttpResponse(null, { status: 404 });
|
||||
if (!store.some((p) => p.id === id))
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
store = store.filter((p) => p.id !== id);
|
||||
delete runtime[id];
|
||||
runs = runs.filter((r) => r.policyId !== id);
|
||||
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.
|
||||
*
|
||||
* The model mirrors the editor + backend policy contract so the portal's
|
||||
* "set up a policy" flow is plug-and-play against the real `/api/v1/policies`
|
||||
* API. A policy is a stored automation: an ordered chain of tool steps (each
|
||||
* step's `operation` is a Stirling endpoint path) plus an output destination,
|
||||
* fired automatically by a trigger (editor upload/export) over a set of
|
||||
* sources. The catalogue groups policies by category, each category carrying a
|
||||
* `PolicyConfigDef` (summary, rules, fields, default tool chain) the setup flow
|
||||
* builds from.
|
||||
* Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec
|
||||
* layer and match the backend record exactly. Catalogue and UI types
|
||||
* (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only:
|
||||
* the backend has no "category" concept — `categoryId` rides in
|
||||
* `output.options`. The catalogue assembles client-side in `api/policies.ts`
|
||||
* from the decoded wire records + these static definitions.
|
||||
*
|
||||
* The wire types (`Policy`, `PipelineStep`) match the backend records exactly;
|
||||
* 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.
|
||||
* api/policies.ts re-exports everything; components never reach in here.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Backend wire model — matches Policy.java / PipelineStep.java exactly */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
import type { WirePipelineStep, WirePolicy } from "@shared/policies/types";
|
||||
import type { PolicyRunView } from "@shared/policies/types";
|
||||
|
||||
/**
|
||||
* A single tool invocation in a policy's pipeline. `operation` is a Stirling
|
||||
* endpoint path (e.g. `/api/v1/security/auto-redact`); `parameters` are the
|
||||
* scalar form fields that endpoint accepts. `fileParameters` binds a tool's
|
||||
* named file field to an asset key in a run's supporting-file store.
|
||||
*/
|
||||
export interface PipelineStep {
|
||||
operation: string;
|
||||
parameters: Record<string, unknown>;
|
||||
fileParameters?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
export type {
|
||||
PolicyActivityItem,
|
||||
PolicyDecodedState,
|
||||
PolicyRunStatus,
|
||||
PolicyRunView,
|
||||
PolicyStats,
|
||||
WireOutputOptions,
|
||||
WireOutputSpec,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@shared/policies/types";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* 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";
|
||||
|
||||
/** A configurable field within a policy's settings. */
|
||||
export type PolicyFieldType = "toggle" | "select" | "chips" | "text";
|
||||
|
||||
export interface PolicyField {
|
||||
label: string;
|
||||
key: string;
|
||||
type: PolicyFieldType;
|
||||
/** Default value: boolean (toggle), string (select/text), string[] (chips). */
|
||||
value: boolean | string | string[];
|
||||
/** Options for select/chips. */
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Static definition of a policy category. The editor's `icon: ReactNode` is
|
||||
* replaced by a string `icon` key the portal resolves to its own glyph.
|
||||
*/
|
||||
export interface PolicyCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Icon key the portal renders (not a component — the portal owns glyphs). */
|
||||
icon: string;
|
||||
/** Visual tone for the category's icon chip. */
|
||||
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
||||
/** Long description shown in the setup flow. */
|
||||
desc: string;
|
||||
/** Drives the "Set up Classification" affordance (doc-type narrowing). */
|
||||
providesClassification?: boolean;
|
||||
/** Locked "Coming soon" — can't be opened or configured. */
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
/** The narrative + field configuration backing a category. */
|
||||
export interface PolicyConfigDef {
|
||||
/** One-line summary of what the policy enforces. */
|
||||
summary: string;
|
||||
/** Pipeline-like rule chips shown in the "Enforces" section. */
|
||||
rules: string[];
|
||||
/** Human label for the scope this policy applies to. */
|
||||
scopeLabel: string;
|
||||
/** Editable policy-level settings fields. */
|
||||
fields: PolicyField[];
|
||||
/**
|
||||
* 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[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
configured: boolean;
|
||||
status: PolicyStatus;
|
||||
/** Selected sources (ids from {@link POLICY_SOURCES}). */
|
||||
sources: string[];
|
||||
/** When non-empty, narrows the policy to these document types. */
|
||||
scopeTypes: string[];
|
||||
/** Email low-confidence enforcements are routed to. */
|
||||
reviewerEmail: string;
|
||||
/** Saved field values, keyed by field key (overrides the definition default). */
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
/** How a run's output is delivered. Defaults to "new_version". */
|
||||
outputMode?: "new_file" | "new_version";
|
||||
/** Rename rule for the output. Empty keeps the input filename. */
|
||||
outputName?: string;
|
||||
/** When the policy runs. Defaults to "upload". */
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||
runOn?: "upload" | "export";
|
||||
/** Backend record id once persisted; used to update/delete/run it. */
|
||||
maxRetries?: number;
|
||||
retryDelayMinutes?: number;
|
||||
backendId?: string;
|
||||
/** A shipped catalogue policy (configurable but not deletable). */
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/** What the setup flow hands back on submit — collected settings + built steps. */
|
||||
export interface PolicySetupResult {
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
sources: string[];
|
||||
@@ -204,57 +88,40 @@ export interface PolicySetupResult {
|
||||
outputName: string;
|
||||
outputNamePosition: "prefix" | "suffix" | "auto-number";
|
||||
runOn: "upload" | "export";
|
||||
/** The configured tool chain as backend pipeline steps. */
|
||||
steps: PipelineStep[];
|
||||
maxRetries: number;
|
||||
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 {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
state: PolicyState;
|
||||
/** The policy's configured steps (drives the detail "Enforces" flow). */
|
||||
steps: PipelineStep[];
|
||||
stats: PolicyStats;
|
||||
activity: PolicyActivityItem[];
|
||||
steps: WirePipelineStep[];
|
||||
stats: import("@shared/policies/types").PolicyStats;
|
||||
activity: import("@shared/policies/types").PolicyActivityItem[];
|
||||
}
|
||||
|
||||
/** Catalogue strip totals shown above the cards. */
|
||||
export interface PoliciesSummary {
|
||||
/** Policies currently active (enabled). */
|
||||
active: number;
|
||||
/** Policies configured but paused. */
|
||||
paused: number;
|
||||
/** Categories available to configure. */
|
||||
categories: number;
|
||||
/** Documents enforced across all active policies. */
|
||||
docsEnforced: number;
|
||||
}
|
||||
|
||||
/** The `GET /api/v1/policies` response, in the portal's catalogue shape. */
|
||||
export interface PoliciesResponse {
|
||||
summary: PoliciesSummary;
|
||||
/** Every catalogue category, each with its definition + (optional) state. */
|
||||
catalogue: CatalogueEntry[];
|
||||
}
|
||||
|
||||
/** One catalogue row: a category, its definition, and its current state. */
|
||||
export interface CatalogueEntry {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
/** The configured policy's runtime view, or null when not yet set up. */
|
||||
policy: DecoratedPolicy | null;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* 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> = {
|
||||
@@ -266,7 +133,6 @@ export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
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> = {
|
||||
"/api/v1/security/auto-redact": "Redact PII",
|
||||
"/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/security/auto-redact" → "Auto Redact" — fallback humanisation. */
|
||||
export function humanizeEndpoint(path: string): string {
|
||||
if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[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 */
|
||||
/* Modelled on the editor's policyDefinitions. PII redact regexes are the */
|
||||
/* precise patterns the /auto-redact endpoint matches (wordsToRedact). */
|
||||
/* Catalogue definitions */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** PII regexes seeded into a Security policy's redact step (SSN + cards). */
|
||||
const DEFAULT_PII_PATTERNS: string[] = [
|
||||
"\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", // SSN
|
||||
"\\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(?!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",
|
||||
];
|
||||
|
||||
export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
@@ -371,10 +233,8 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
security: {
|
||||
summary:
|
||||
"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",
|
||||
// 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: [
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
@@ -395,9 +255,14 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
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: [],
|
||||
},
|
||||
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[] = [
|
||||
"Contracts",
|
||||
"Invoices",
|
||||
@@ -533,80 +359,86 @@ export const POLICY_DOC_TYPES: string[] = [
|
||||
];
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Seed policies — a few configured policies in the wire shape, so the store */
|
||||
/* behaves like a backend that already has policies set up. */
|
||||
/* Seed data — real backend wire format */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The shipped default policies the handlers seed the store with. */
|
||||
export function seedPolicies(): Policy[] {
|
||||
export function seedPolicies(): WirePolicy[] {
|
||||
return [
|
||||
{
|
||||
id: "pol_security_default",
|
||||
name: "Security Policy",
|
||||
owner: "security@acme.com",
|
||||
enabled: true,
|
||||
trigger: { event: "upload" },
|
||||
sources: [{ source: "editor" }],
|
||||
trigger: null,
|
||||
steps: POLICY_CONFIG.security.defaultOperations,
|
||||
output: { mode: "new_version", name: "", namePosition: "suffix" },
|
||||
output: {
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-policy runtime extras keyed by policy id — the parts the wire record
|
||||
* doesn't carry (collected field values, scope, derived stats + activity).
|
||||
* In a real backend these would be derived server-side from the user's files.
|
||||
*/
|
||||
export interface PolicyRuntime {
|
||||
scopeTypes: string[];
|
||||
reviewerEmail: string;
|
||||
fieldValues: Record<string, boolean | string | string[]>;
|
||||
stats: PolicyStats;
|
||||
activity: PolicyActivityItem[];
|
||||
isDefault?: boolean;
|
||||
}
|
||||
const NOW = Date.now();
|
||||
const M = 60000;
|
||||
const H = 3600000;
|
||||
const D = 86400000;
|
||||
|
||||
export function seedRuntime(): Record<string, PolicyRuntime> {
|
||||
return {
|
||||
pol_security_default: {
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "security@acme.com",
|
||||
fieldValues: {},
|
||||
isDefault: true,
|
||||
stats: { enforced: 4821, dataProcessed: "2.3 GB", activeFor: "34d" },
|
||||
activity: [
|
||||
/** Seed `PolicyRunView` records that drive the activity feed + stats. */
|
||||
export function seedPolicyRuns(): PolicyRunView[] {
|
||||
return [
|
||||
{
|
||||
doc: "Q2-vendor-agreement.pdf",
|
||||
action: "Redacted 6 PII matches • JavaScript stripped",
|
||||
time: "12m ago",
|
||||
status: "enforced",
|
||||
runId: "run_001",
|
||||
policyId: "pol_security_default",
|
||||
status: "COMPLETED",
|
||||
currentStep: 2,
|
||||
stepCount: 2,
|
||||
error: null,
|
||||
outputs: [{ fileId: "f1", fileName: "Q2-vendor-agreement.pdf" }],
|
||||
createdAt: NOW - 12 * M,
|
||||
},
|
||||
{
|
||||
doc: "patient-intake-0481.pdf",
|
||||
action: "Low-confidence match — routed for review",
|
||||
time: "1h ago",
|
||||
status: "flagged",
|
||||
runId: "run_002",
|
||||
policyId: "pol_security_default",
|
||||
status: "FAILED",
|
||||
currentStep: 1,
|
||||
stepCount: 2,
|
||||
error: "Low-confidence match — routed for review",
|
||||
outputs: [{ fileId: "f2", fileName: "patient-intake-0481.pdf" }],
|
||||
createdAt: NOW - 1 * H,
|
||||
},
|
||||
{
|
||||
doc: "invoice-7782.pdf",
|
||||
action: "Enforcing…",
|
||||
time: "just now",
|
||||
status: "processing",
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Empty stats/activity for a freshly-configured policy with no runs yet. */
|
||||
export function emptyRuntime(reviewerEmail = "you@acme.com"): PolicyRuntime {
|
||||
return {
|
||||
scopeTypes: [],
|
||||
reviewerEmail,
|
||||
fieldValues: {},
|
||||
stats: { enforced: 0, dataProcessed: "0 B", activeFor: "—" },
|
||||
activity: [],
|
||||
};
|
||||
// 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 */
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -53,10 +48,47 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.portal-policies__card-head {
|
||||
.portal-policies__card-identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@@ -66,41 +98,8 @@
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-policies__card-title {
|
||||
@@ -110,50 +109,6 @@
|
||||
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 */
|
||||
.portal-policies__wizard-title {
|
||||
display: inline-flex;
|
||||
@@ -196,6 +151,13 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -225,17 +187,6 @@
|
||||
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 */
|
||||
.portal-policies__sources {
|
||||
display: grid;
|
||||
@@ -251,12 +202,12 @@
|
||||
|
||||
.portal-policies__source {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -264,6 +215,22 @@
|
||||
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 {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
@@ -273,12 +240,26 @@
|
||||
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 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policies__source--on .portal-policies__source-icon {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-policies__source-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -338,8 +319,13 @@
|
||||
.portal-policies__detail-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-policies__detail-sep {
|
||||
color: var(--color-text-5);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-policies__detail-meta {
|
||||
@@ -347,28 +333,31 @@
|
||||
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 {
|
||||
color: var(--color-text-5);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-policies__enforce-note {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
.portal-policies__detail-inline {
|
||||
display: flex;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -384,22 +373,32 @@
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-policies__activity-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
.portal-policies__activity-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.375rem;
|
||||
border-radius: 50%;
|
||||
margin-top: 0.125rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.portal-policies__activity-dot--success {
|
||||
background: var(--color-green);
|
||||
.portal-policies__activity-icon--success {
|
||||
color: var(--color-green);
|
||||
}
|
||||
.portal-policies__activity-dot--warning {
|
||||
background: var(--color-amber);
|
||||
.portal-policies__activity-icon--warning {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
.portal-policies__activity-dot--info {
|
||||
background: var(--color-blue);
|
||||
.portal-policies__activity-icon--info {
|
||||
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 {
|
||||
@@ -427,6 +426,29 @@
|
||||
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 */
|
||||
.portal-policies__detail-stats {
|
||||
display: grid;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useCallback, useState } from "react";
|
||||
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 {
|
||||
buildWireFromSetup,
|
||||
buildWireFromState,
|
||||
deletePolicy,
|
||||
fetchPolicies,
|
||||
runPolicy,
|
||||
savePolicy,
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
type Policy,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
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 "@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() {
|
||||
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 state = useAsync<PoliciesResponse>(() => fetchPolicies(), [version]);
|
||||
const { data, loading } = state;
|
||||
const { data, loading, error: fetchError } = 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 [wizard, setWizard] = useState<CatalogueEntry | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
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) {
|
||||
if (entry.policy) setDetail(entry);
|
||||
else setWizard(entry);
|
||||
@@ -72,50 +60,39 @@ export function Policies() {
|
||||
entry: CatalogueEntry,
|
||||
result: PolicySetupResult,
|
||||
) {
|
||||
await savePolicy(toWirePolicy(entry, result));
|
||||
setPageError(null);
|
||||
try {
|
||||
await savePolicy(buildWireFromSetup(entry, result));
|
||||
setWizard(null);
|
||||
setDetail(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setPageError(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function runLifecycle(action: () => Promise<unknown>) {
|
||||
if (busy) return;
|
||||
setPageError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
setDetail(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setPageError(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRun() {
|
||||
const id = detail?.policy?.state.backendId;
|
||||
if (id) void runLifecycle(() => runPolicy(id));
|
||||
}
|
||||
|
||||
function handleTogglePause() {
|
||||
const entry = detail;
|
||||
const policy = entry?.policy;
|
||||
if (!entry || !policy?.state.backendId) return;
|
||||
// Pause/resume is a re-save with the enabled flag flipped (the backend has
|
||||
// no dedicated endpoint — every mutation routes through POST /policies).
|
||||
const enabled = policy.state.status === "paused";
|
||||
void runLifecycle(() =>
|
||||
savePolicy({
|
||||
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,
|
||||
}),
|
||||
savePolicy(buildWireFromState(entry, policy, enabled)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +101,6 @@ export function Policies() {
|
||||
if (id) void runLifecycle(() => deletePolicy(id));
|
||||
}
|
||||
|
||||
// Reopen the wizard for the policy currently shown in the detail panel.
|
||||
function handleEdit() {
|
||||
if (detail) {
|
||||
setWizard(detail);
|
||||
@@ -139,19 +115,34 @@ export function Policies() {
|
||||
<p className="portal-policies__sub">{t("policies.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
{pageError && <Banner tone="danger" description={pageError} />}
|
||||
|
||||
<CatalogueSummary data={data} loading={loading} />
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-policies__grid" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} height="11rem" />
|
||||
<Skeleton key={i} height="3.5rem" />
|
||||
))}
|
||||
</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">
|
||||
{catalogue.map((entry) => (
|
||||
{displayCatalogue.map((entry) => (
|
||||
<PolicyCategoryCard
|
||||
key={entry.category.id}
|
||||
entry={entry}
|
||||
@@ -166,7 +157,6 @@ export function Policies() {
|
||||
busy={busy}
|
||||
onClose={() => setDetail(null)}
|
||||
onEdit={handleEdit}
|
||||
onRun={handleRun}
|
||||
onTogglePause={handleTogglePause}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
@@ -23,12 +23,19 @@ export default defineConfig({
|
||||
"@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: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
css: false,
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
include: ["src/**/*.test.{ts,tsx}", "../shared/**/*.test.ts"],
|
||||
testTimeout: 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.
|
||||
#
|
||||
# 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
|
||||
# TcpListener on loopback. Tracks picks within this run so outputs are
|
||||
# guaranteed distinct from each other.
|
||||
# emits a random free port in 20000-49999. Uses Get-NetTCPConnection to read
|
||||
# the OS socket table directly — more reliable than TcpListener binding on
|
||||
# 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)
|
||||
|
||||
$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 {
|
||||
param([int]$Port)
|
||||
if ($script:picked -contains $Port) { return $false }
|
||||
try {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
|
||||
$listener.Start()
|
||||
$listener.Stop()
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
return -not $usedPorts.Contains($Port)
|
||||
}
|
||||
|
||||
function Get-RandomFreePort {
|
||||
|
||||
Reference in New Issue
Block a user