mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Policies: enforce on upload or export (#6614)
Follow-up to #6604 (merged). Builds the Security policy out so it actually enforces, driven from the editor. ## What it does - **Run on upload or export** — a single choice in the wizard: enforce when a file is uploaded, or just before it's exported. - **Output** — enforced result is a **new version** of the file (default) or a **new file**, with optional filename prefix/suffix/auto-number ("Output filename" subsection; auto-number only for new files). - **Export enforcement** — exporting an export-mode file runs the policy first and downloads the enforced result; never hard-blocks (on failure the original downloads). For new-version policies the in-editor file is versioned too. Covers every export path incl. multi-file ZIP. A toast (glowing in the policy's accent while it runs) reports progress and fades after ~10s. - **Affordances** — a freshly enforced file briefly glows its policy accent and carries a shield badge. - **Config tidy-up** — removed the unwired Security setting fields + the wizard's review step; "Upgrade to enterprise" on locked categories; category accent in the detail/wizard headers. ## Notes - Builds on the manual-only (client-driven) policy model from #6587 (`trigger: null`, metadata in `output.options`), adding the `runOn` field + export-time enforcement. - The page-editor merge-export (no single source file) enforces + downloads but doesn't version in place. ## Verification typecheck (core + proprietary), eslint, prettier; proprietary suite (105) green; flows checked in-app.
This commit is contained in:
@@ -222,6 +222,34 @@
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
|
||||
directly below it carries data-first so the borders don't double up. */
|
||||
.pol-subhead {
|
||||
padding: 0.55rem 0.875rem 0.4rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Position dropdown + custom-text input sitting on one row under the
|
||||
"Output filename" subhead — the dropdown sizes to content, text fills. */
|
||||
.pol-name-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.pol-name-row > :first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.pol-name-row > :last-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
|
||||
.pol-field {
|
||||
padding: 0.7rem 0.875rem;
|
||||
|
||||
@@ -112,14 +112,14 @@ describe("Policies right-sidebar surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Security as active and the unbuilt categories as Coming soon", () => {
|
||||
it("shows Security as active and the unbuilt categories as upgrade-gated", () => {
|
||||
renderHost();
|
||||
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
|
||||
// Ingestion, Compliance, Routing, Retention are locked for this release.
|
||||
expect(screen.getAllByText("Coming soon")).toHaveLength(4);
|
||||
expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("does not open a Coming soon policy when its row is clicked", () => {
|
||||
it("does not open an upgrade-gated policy when its row is clicked", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Ingestion"));
|
||||
// The locked row isn't a button — we stay on the list, nothing opens.
|
||||
|
||||
@@ -151,7 +151,9 @@ export function PoliciesSection({
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">{cat.label}</span>
|
||||
<span className="pol-row-trail">
|
||||
<span className="pol-row-soon">Coming soon</span>
|
||||
<span className="pol-row-soon">
|
||||
Upgrade to enterprise
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,8 +80,7 @@ interface PolicySetupWizardProps {
|
||||
* The shared policy wizard, used for both setup and edit. Two steps: Workflow
|
||||
* (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy
|
||||
* fields + output/retry config). The workflow builder is kept mounted across
|
||||
* steps so the final action can trigger its save. (A Sources step exists in
|
||||
* code, gated off by SOURCES_IN_FLOW, for when non-editor sources return.)
|
||||
* steps so the final action can trigger its save.
|
||||
*/
|
||||
export function PolicySetupWizard({
|
||||
category,
|
||||
@@ -113,8 +112,8 @@ export function PolicySetupWizard({
|
||||
);
|
||||
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
|
||||
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
|
||||
// Reviewer is no longer configured in the flow (there's no human-review step),
|
||||
// but the field is kept in the saved policy, defaulted to the signed-in user.
|
||||
// Reviewer isn't shown in the flow; the field is still saved on the policy,
|
||||
// defaulted to the signed-in user.
|
||||
const reviewerEmail = initial.reviewerEmail || user?.email || "";
|
||||
// Output + retry settings — the real, working folder settings (the engine
|
||||
// applies them). Pre-filled from the backing folder in edit mode.
|
||||
@@ -129,6 +128,10 @@ export function PolicySetupWizard({
|
||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
||||
initialFolder?.retryDelayMinutes ?? 5,
|
||||
);
|
||||
// The editor event this policy runs on: input on upload, or output on export.
|
||||
const [runOn, setRunOn] = useState<"upload" | "export">(
|
||||
initial.runOn ?? "upload",
|
||||
);
|
||||
const workflowSave = useRef<(() => void) | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
@@ -197,6 +200,7 @@ export function PolicySetupWizard({
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
reviewerEmail,
|
||||
folder: {
|
||||
runOn,
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
@@ -229,6 +233,7 @@ export function PolicySetupWizard({
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
reviewerEmail,
|
||||
folder: {
|
||||
runOn,
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
@@ -329,35 +334,69 @@ export function PolicySetupWizard({
|
||||
{step === 2 && (
|
||||
<>
|
||||
<p className="pol-desc">{category.desc}</p>
|
||||
<Card padding="none">
|
||||
{config.fields.map((f, i) => (
|
||||
<PolicyFieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={fieldValues[f.key]}
|
||||
first={i === 0}
|
||||
onChange={(v) =>
|
||||
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
{config.fields.length > 0 && (
|
||||
<Card padding="none">
|
||||
{config.fields.map((f, i) => (
|
||||
<PolicyFieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={fieldValues[f.key]}
|
||||
first={i === 0}
|
||||
onChange={(v) =>
|
||||
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Real, working output + retry settings (applied by the engine). */}
|
||||
<p className="pol-section-label">Output & retries</p>
|
||||
<Card padding="none">
|
||||
{/* The editor event the policy runs on: input on upload, or
|
||||
output on export (enforced before the file is exported). */}
|
||||
<div className="pol-subhead">Run on</div>
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label="Output"
|
||||
label="Run on"
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={runOn}
|
||||
onChange={(e) =>
|
||||
setRunOn(e.target.value as "upload" | "export")
|
||||
}
|
||||
aria-label="Run on"
|
||||
options={[
|
||||
{ value: "upload", label: "Upload" },
|
||||
{ value: "export", label: "Export" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-subhead">Output</div>
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label="Output as"
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputMode}
|
||||
onChange={(e) =>
|
||||
setOutputMode(
|
||||
e.target.value as "new_file" | "new_version",
|
||||
)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as
|
||||
| "new_file"
|
||||
| "new_version";
|
||||
setOutputMode(mode);
|
||||
// Auto-number only applies to new files; a new version
|
||||
// replaces the file in place, so fall back to suffix.
|
||||
if (
|
||||
mode === "new_version" &&
|
||||
outputNamePosition === "auto-number"
|
||||
) {
|
||||
setOutputNamePosition("suffix");
|
||||
}
|
||||
}}
|
||||
aria-label="Output mode"
|
||||
options={[
|
||||
{ value: "new_file", label: "New file" },
|
||||
@@ -367,41 +406,40 @@ export function PolicySetupWizard({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Add to filename"
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputNamePosition}
|
||||
onChange={(e) =>
|
||||
setOutputNamePosition(
|
||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
aria-label="Add to filename"
|
||||
options={[
|
||||
{ value: "prefix", label: "Prefix" },
|
||||
{ value: "suffix", label: "Suffix" },
|
||||
{ value: "auto-number", label: "Auto-number" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label="Text to add"
|
||||
control={
|
||||
{/* Output filename: position + custom text together as one row. */}
|
||||
<div className="pol-subhead">Output filename</div>
|
||||
<div className="pol-field" data-first>
|
||||
<div className="pol-name-row">
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputNamePosition}
|
||||
onChange={(e) =>
|
||||
setOutputNamePosition(
|
||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
aria-label="Filename position"
|
||||
options={[
|
||||
{ value: "prefix", label: "Prefix" },
|
||||
{ value: "suffix", label: "Suffix" },
|
||||
// Auto-number only makes sense for separate new files.
|
||||
...(outputMode === "new_file"
|
||||
? [{ value: "auto-number", label: "Auto-number" }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{/* Auto-number names the file itself, so there's no custom
|
||||
text to add — only show the input for prefix/suffix. */}
|
||||
{outputNamePosition !== "auto-number" && (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
placeholder="optional"
|
||||
aria-label="Text to add"
|
||||
placeholder="Text to add (optional)"
|
||||
aria-label="Filename text"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
|
||||
@@ -96,7 +96,6 @@ export function PolicyToolConfig({
|
||||
</div>
|
||||
{tool.enabled &&
|
||||
(tool.operation === "redact" ? (
|
||||
// Redact config is reduced to just the PII type picker.
|
||||
<div className="pol-tool-body">
|
||||
<PolicyRedactConfig
|
||||
parameters={tool.parameters}
|
||||
@@ -109,8 +108,8 @@ export function PolicyToolConfig({
|
||||
// are fixed in the policy preset), so no settings are shown.
|
||||
<></>
|
||||
) : tool.operation === "watermark" ? (
|
||||
// Watermark: full settings minus the "Flatten PDF pages to
|
||||
// images" toggle (hidden), with flatten forced on.
|
||||
// Watermark settings with flatten hidden + forced on (see
|
||||
// PolicyWatermarkConfig).
|
||||
<div className="pol-tool-body">
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tool.parameters}
|
||||
|
||||
@@ -73,14 +73,19 @@ export function usePolicyAutoRun(): void {
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
const active = Object.entries(policies).filter(
|
||||
([, s]) => s.configured && s.status === "active" && s.backendId,
|
||||
([, s]) =>
|
||||
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).
|
||||
(s.runOn ?? "upload") === "upload",
|
||||
);
|
||||
for (const [categoryId, s] of active) {
|
||||
for (const stub of fileStubs) {
|
||||
const key = dispatchKey(categoryId, stub.id);
|
||||
// Skip if already run (persisted) or a dispatch is mid-flight (this
|
||||
// session) — runPolicyOnFile now waits for the file's bytes to commit,
|
||||
// so the in-memory guard, not an eager mark, prevents double-firing.
|
||||
// Skip if already run (persisted) or a dispatch is in flight — the
|
||||
// in-memory guard prevents double-firing during the async wait.
|
||||
if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user