Files
Stirling-PDF/frontend/editor/src/proprietary/services/policyStorage.ts
T
Reece BrowneandGitHub 11ab762f57 feat(policies): config refinements + new-version output (post-#6598) (#6604)
Follow-up to #6598 (squash-merged into `SaaS`). These are the policy
refinements made after that merge, against the current `SaaS` tip.

## Changes
- **Simplify Security config + plain-language info buttons** — Redact
config reduced to the PII field; Sanitise has no config
(JavaScript-removal only) with a non-technical info button; per-tool
info buttons reworded to match the tool-steps style.
- **Hide 'Flatten PDF pages to images' from the watermark policy
config** — new `PolicyWatermarkConfig` wrapping the watermark settings
with the flatten checkbox gated off.
- **Flatten-to-image on by default for redact + watermark** — both
normalise `convertPDFToImage: true` on mount.
- **Self-heal a stale backing folder** — `ensurePolicyFolder` recreates
a backing folder whose `folderId` no longer resolves (preferring the
backend's stored automation), instead of hanging Edit Settings on a
permanent "Loading…".
- **Version the input file on 'new version' output mode** — completed
runs whose policy output mode is `new_version` replace the input file
with a versioned child (origin tool `automate`) rather than adding a
separate file; falls back to a new file if the input is gone.
`outputMode` is plumbed through `PolicyState`, the local-cache default,
and backend reconciliation.

## Verification
- `typecheck:proprietary` + `typecheck:core` clean
- policy + hooks vitest: 17 passing
- eslint + prettier clean on all changed files
2026-06-11 14:45:22 +01:00

116 lines
4.1 KiB
TypeScript

/**
* Local cache + offline fallback for Policies — the backend (/api/v1/policies)
* is the source of truth. Holds per-category state (configured/active/paused,
* sources, scope, reviewer, field overrides) and broadcasts changes so hooks
* re-render. Mirrors the change-event pattern of the automation/folder stores.
*/
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import type { PoliciesByCategory, PolicyState } from "@app/types/policies";
const STORAGE_KEY = "stirling-policies-state";
export const POLICIES_CHANGE_EVENT = "stirling:policies-changed";
function defaultState(): PolicyState {
// Unconfigured by default. The backend is the source of truth for what's
// actually configured + active; this is just the empty local-cache shape.
return {
configured: false,
status: "default",
sources: ["editor"],
scopeTypes: [],
// Empty by default; the wizard defaults the reviewer to the signed-in user.
reviewerEmail: "",
fieldValues: {},
// Default to versioning the input file rather than spawning a separate one.
outputMode: "new_version",
// No rename by default — the output keeps the input's filename.
outputName: "",
// Every catalog category is a shipped, built-in policy → default (not
// deletable). User-created policies (later) will set this false.
isDefault: true,
};
}
/** An obsolete reviewer email from earlier development, scrubbed from persisted
* state on read so it can re-default to the real signed-in user. */
const STALE_REVIEWER_EMAIL = "[email protected]";
/** Read the full policy state, seeding + healing any missing categories. */
export function loadPolicies(): PoliciesByCategory {
let parsed: Partial<PoliciesByCategory> = {};
try {
const raw =
typeof localStorage !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (raw) parsed = JSON.parse(raw) as Partial<PoliciesByCategory>;
} catch {
// Corrupt/unavailable storage — fall back to seed.
}
// Always reconcile against the current category list so a newly-added
// category gets a default rather than being undefined.
const out: PoliciesByCategory = {};
for (const cat of loadPolicyCatalog().categories) {
const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
// Migration: clear the obsolete persisted reviewer email so it re-defaults
// to the real signed-in user.
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
merged.reviewerEmail = "";
out[cat.id] = merged;
}
return out;
}
function persist(state: PoliciesByCategory): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
} catch {
// Best-effort; ignore quota/availability failures.
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(POLICIES_CHANGE_EVENT));
}
}
/** Merge a partial update into one category's state and persist. */
export function updatePolicy(
categoryId: string,
patch: Partial<PolicyState>,
): PoliciesByCategory {
const current = loadPolicies();
const next: PoliciesByCategory = {
...current,
// Fall back to defaults so a not-yet-seeded category id still yields a
// complete PolicyState rather than a partial.
[categoryId]: {
...defaultState(),
...current[categoryId],
...patch,
},
};
persist(next);
return next;
}
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
...defaultState(),
configured: false,
status: "default",
// Drop the backing-folder + backend links (the caller deletes those).
folderId: undefined,
backendId: undefined,
});
}
/** Subscribe to policy-state changes (same-tab). Returns an unsubscribe fn. */
export function onPoliciesChange(cb: () => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(POLICIES_CHANGE_EVENT, cb);
return () => window.removeEventListener(POLICIES_CHANGE_EVENT, cb);
}