Builds the §3 workflows editor as a client component at
`/[slug]/workflows`. IT_ADMIN only. Full-bleed layout (own
`layout.tsx`) — palette (234px) + canvas (flex) + inspector (286px).
* `src/lib/flow-modules.ts` — TS port of the handoff `FLOW_MODULES`
catalog: 18 modules across Triggers / Scanner / CERTifAI / Logic /
Actions, each with kind-colored monogram, input/output ports, and a
typed settings schema (select / text / num / area / toggle). Helpers
for `nodeH`, `portX/Y`, `defConfig`, `wirePath` (bezier), `seedFlow`
(7-node sample workflow), `modsByCat`. KIND_COLOR token map.
* `src/components/portal/workflows/WorkflowEditor.tsx` — client
component with:
- Palette: collapsible category tree, draggable items, kind-colored
dots and monos.
- Canvas: dotted grid that pans (drag background) and zooms (+/− with
`Maximize2` reset, 0.5–1.6). Floating toolbar = workflow name input
(running pulse on the dot during a test run) + node/link count +
Validate / Save / **Test run** buttons. Save respects the frozen
write-guard; Test run highlights nodes in BFS order from triggers
with animated wires (`.wire.run` keyframes already in globals.css).
- Nodes: 202px cards with kind-bordered monogram + title, first-config
value or `desc` in the body, input ports on left, output ports on
right (multi-output gates labeled PASS/FAIL, etc.). Drag to move,
click to select. Delete/Backspace removes selection.
- Wires: bezier paths via `wirePath`. Drag output port → input port
creates an edge (replaces existing edges into that input). Click to
select. Pending wire shows dashed.
- Inspector: live form against `selNode.config` driven by the module's
settings schema. Per-type fields (select / text / num / area /
toggle). Empty state shows the kind legend; edge selection shows a
delete-link affordance.
- Toasts: inline bottom-right queue with mono status-code footer for
the workflow actions (`workflow.valid`, `workflow.saved`,
`workflow.tested`, `402 → reactivation.requested` when frozen).
* `src/app/[slug]/workflows/layout.tsx` — strips `.content-inner` and
fills `position: absolute; inset: 0` so the editor's 3-column flex
fills the entire content area.
The page returns 200 against `BP_DEV_FIXTURE=admin-acme` with every
flow-* class marker present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
445 lines
11 KiB
TypeScript
445 lines
11 KiB
TypeScript
// Module catalog for the Workflows editor — TS port of FLOW_MODULES /
|
|
// FLOW_CATS from the design handoff's `screens_flow.jsx`. The shapes here
|
|
// drive the palette tree, the per-node ports, and the inspector form.
|
|
|
|
export type FlowKind = "trigger" | "scanner" | "ai" | "logic" | "action";
|
|
|
|
export const KIND_COLOR: Record<FlowKind, string> = {
|
|
trigger: "var(--accent)",
|
|
scanner: "var(--ink-2)",
|
|
ai: "var(--ink-2)",
|
|
logic: "var(--warn)",
|
|
action: "var(--ok)",
|
|
};
|
|
|
|
export type FlowCat = {
|
|
id: string;
|
|
label: string;
|
|
kind: FlowKind;
|
|
};
|
|
|
|
export const FLOW_CATS: FlowCat[] = [
|
|
{ id: "triggers", label: "Triggers", kind: "trigger" },
|
|
{ id: "scanner", label: "Compliance Scanner", kind: "scanner" },
|
|
{ id: "certifai", label: "CERTifAI", kind: "ai" },
|
|
{ id: "logic", label: "Logic", kind: "logic" },
|
|
{ id: "actions", label: "Actions", kind: "action" },
|
|
];
|
|
|
|
export type FlowSetting =
|
|
| { k: string; label: string; type: "select"; opts: string[]; def: string; ph?: string }
|
|
| { k: string; label: string; type: "text"; def: string; ph?: string }
|
|
| { k: string; label: string; type: "num"; def: number; ph?: string }
|
|
| { k: string; label: string; type: "area"; def: string; ph?: string }
|
|
| { k: string; label: string; type: "toggle"; def: boolean; ph?: string };
|
|
|
|
export type FlowModule = {
|
|
name: string;
|
|
cat: string;
|
|
kind: FlowKind;
|
|
mono: string;
|
|
in: string[];
|
|
out: string[];
|
|
desc: string;
|
|
settings?: FlowSetting[];
|
|
};
|
|
|
|
export const FLOW_MODULES: Record<string, FlowModule> = {
|
|
// ---- triggers ----
|
|
schedule: {
|
|
name: "On schedule",
|
|
cat: "triggers",
|
|
kind: "trigger",
|
|
mono: "⏱",
|
|
in: [],
|
|
out: ["out"],
|
|
desc: "daily · 02:00",
|
|
settings: [
|
|
{
|
|
k: "cadence",
|
|
label: "Cadence",
|
|
type: "select",
|
|
opts: ["Every hour", "Daily", "Weekly", "Monthly"],
|
|
def: "Daily",
|
|
},
|
|
{ k: "time", label: "At time (UTC)", type: "text", def: "02:00" },
|
|
],
|
|
},
|
|
"scan-complete": {
|
|
name: "On scan complete",
|
|
cat: "triggers",
|
|
kind: "trigger",
|
|
mono: "◆",
|
|
in: [],
|
|
out: ["out"],
|
|
desc: "compliance-scanner",
|
|
settings: [
|
|
{
|
|
k: "product",
|
|
label: "Product",
|
|
type: "select",
|
|
opts: ["compliance-scanner", "certifai", "any"],
|
|
def: "compliance-scanner",
|
|
},
|
|
],
|
|
},
|
|
"new-finding": {
|
|
name: "On new finding",
|
|
cat: "triggers",
|
|
kind: "trigger",
|
|
mono: "▲",
|
|
in: [],
|
|
out: ["out"],
|
|
desc: "severity ≥ Medium",
|
|
settings: [
|
|
{
|
|
k: "minSev",
|
|
label: "Min severity",
|
|
type: "select",
|
|
opts: ["Low", "Medium", "High", "Critical"],
|
|
def: "Medium",
|
|
},
|
|
],
|
|
},
|
|
webhook: {
|
|
name: "Webhook",
|
|
cat: "triggers",
|
|
kind: "trigger",
|
|
mono: "↯",
|
|
in: [],
|
|
out: ["out"],
|
|
desc: "POST /hooks/…",
|
|
settings: [
|
|
{ k: "path", label: "Path", type: "text", def: "/hooks/ingest" },
|
|
{ k: "secret", label: "Signing secret", type: "text", def: "whsec_••••" },
|
|
],
|
|
},
|
|
|
|
// ---- scanner ----
|
|
"run-scan": {
|
|
name: "Run scan",
|
|
cat: "scanner",
|
|
kind: "scanner",
|
|
mono: "CS",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "full · cloud + code",
|
|
settings: [
|
|
{
|
|
k: "scope",
|
|
label: "Scope",
|
|
type: "select",
|
|
opts: ["Full", "Cloud only", "Code only", "Delta"],
|
|
def: "Full",
|
|
},
|
|
{ k: "frameworks", label: "Frameworks", type: "text", def: "ISO 27001, BSI C5" },
|
|
],
|
|
},
|
|
filter: {
|
|
name: "Filter findings",
|
|
cat: "scanner",
|
|
kind: "scanner",
|
|
mono: "≡",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "status = open",
|
|
settings: [
|
|
{
|
|
k: "status",
|
|
label: "Status",
|
|
type: "select",
|
|
opts: ["open", "resolved", "any"],
|
|
def: "open",
|
|
},
|
|
{ k: "control", label: "Control matches", type: "text", def: "", ph: "e.g. ISO 27001 A.8*" },
|
|
],
|
|
},
|
|
"sev-gate": {
|
|
name: "Severity gate",
|
|
cat: "scanner",
|
|
kind: "scanner",
|
|
mono: "⊟",
|
|
in: ["in"],
|
|
out: ["pass", "fail"],
|
|
desc: "≥ High",
|
|
settings: [
|
|
{
|
|
k: "threshold",
|
|
label: "Threshold",
|
|
type: "select",
|
|
opts: ["Low", "Medium", "High", "Critical"],
|
|
def: "High",
|
|
},
|
|
],
|
|
},
|
|
"map-control": {
|
|
name: "Map to control",
|
|
cat: "scanner",
|
|
kind: "scanner",
|
|
mono: "⌖",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "framework: ISO 27001",
|
|
settings: [
|
|
{
|
|
k: "framework",
|
|
label: "Framework",
|
|
type: "select",
|
|
opts: ["ISO 27001", "BSI C5", "NIS2", "TISAX"],
|
|
def: "ISO 27001",
|
|
},
|
|
],
|
|
},
|
|
|
|
// ---- certifai ----
|
|
"gen-annex": {
|
|
name: "Generate Annex IV",
|
|
cat: "certifai",
|
|
kind: "ai",
|
|
mono: "Ai",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "EU AI Act dossier",
|
|
settings: [
|
|
{
|
|
k: "model",
|
|
label: "Model system",
|
|
type: "select",
|
|
opts: ["risk-scorer-v3", "doc-classifier", "all"],
|
|
def: "risk-scorer-v3",
|
|
},
|
|
{ k: "sign", label: "Sign dossier", type: "toggle", def: true },
|
|
],
|
|
},
|
|
"bias-check": {
|
|
name: "Bias evaluation",
|
|
cat: "certifai",
|
|
kind: "ai",
|
|
mono: "Ai",
|
|
in: ["in"],
|
|
out: ["pass", "fail"],
|
|
desc: "fairness ≥ 0.8",
|
|
settings: [
|
|
{
|
|
k: "metric",
|
|
label: "Metric",
|
|
type: "select",
|
|
opts: ["Demographic parity", "Equalised odds"],
|
|
def: "Demographic parity",
|
|
},
|
|
{ k: "min", label: "Min score", type: "num", def: 0.8 },
|
|
],
|
|
},
|
|
"model-audit": {
|
|
name: "Model card audit",
|
|
cat: "certifai",
|
|
kind: "ai",
|
|
mono: "Ai",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "Annex IV §2",
|
|
settings: [{ k: "strict", label: "Strict mode", type: "toggle", def: false }],
|
|
},
|
|
|
|
// ---- logic ----
|
|
branch: {
|
|
name: "Branch",
|
|
cat: "logic",
|
|
kind: "logic",
|
|
mono: "⑂",
|
|
in: ["in"],
|
|
out: ["true", "false"],
|
|
desc: "if condition",
|
|
settings: [
|
|
{ k: "expr", label: "Condition", type: "text", def: "count > 0", ph: "expression" },
|
|
],
|
|
},
|
|
merge: {
|
|
name: "Merge",
|
|
cat: "logic",
|
|
kind: "logic",
|
|
mono: "⑃",
|
|
in: ["a", "b"],
|
|
out: ["out"],
|
|
desc: "wait all",
|
|
settings: [
|
|
{
|
|
k: "mode",
|
|
label: "Mode",
|
|
type: "select",
|
|
opts: ["Wait all", "First wins"],
|
|
def: "Wait all",
|
|
},
|
|
],
|
|
},
|
|
delay: {
|
|
name: "Delay",
|
|
cat: "logic",
|
|
kind: "logic",
|
|
mono: "◴",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "1 h",
|
|
settings: [{ k: "amount", label: "Duration", type: "text", def: "1 h" }],
|
|
},
|
|
|
|
// ---- actions ----
|
|
"create-evidence": {
|
|
name: "Create evidence",
|
|
cat: "actions",
|
|
kind: "action",
|
|
mono: "▤",
|
|
in: ["in"],
|
|
out: ["out"],
|
|
desc: "PDF · signed",
|
|
settings: [
|
|
{ k: "name", label: "Bundle name", type: "text", def: "Auto-evidence" },
|
|
{
|
|
k: "format",
|
|
label: "Format",
|
|
type: "select",
|
|
opts: ["PDF", "JSON", "PDF + JSON"],
|
|
def: "PDF",
|
|
},
|
|
{ k: "sign", label: "Hash-chain sign", type: "toggle", def: true },
|
|
],
|
|
},
|
|
notify: {
|
|
name: "Notify",
|
|
cat: "actions",
|
|
kind: "action",
|
|
mono: "✉",
|
|
in: ["in"],
|
|
out: [],
|
|
desc: "Slack · #compliance",
|
|
settings: [
|
|
{
|
|
k: "channel",
|
|
label: "Channel",
|
|
type: "select",
|
|
opts: ["Slack", "Email", "Microsoft Teams"],
|
|
def: "Slack",
|
|
},
|
|
{ k: "target", label: "Target", type: "text", def: "#compliance" },
|
|
{ k: "msg", label: "Message", type: "area", def: "{{count}} findings need review" },
|
|
],
|
|
},
|
|
"open-ticket": {
|
|
name: "Open ticket",
|
|
cat: "actions",
|
|
kind: "action",
|
|
mono: "⊞",
|
|
in: ["in"],
|
|
out: [],
|
|
desc: "Jira · COMP",
|
|
settings: [
|
|
{
|
|
k: "system",
|
|
label: "System",
|
|
type: "select",
|
|
opts: ["Jira", "ServiceNow", "Linear"],
|
|
def: "Jira",
|
|
},
|
|
{ k: "project", label: "Project key", type: "text", def: "COMP" },
|
|
],
|
|
},
|
|
"export-bundle": {
|
|
name: "Export bundle",
|
|
cat: "actions",
|
|
kind: "action",
|
|
mono: "⇪",
|
|
in: ["in"],
|
|
out: [],
|
|
desc: "S3 · eu-central",
|
|
settings: [
|
|
{
|
|
k: "dest",
|
|
label: "Destination",
|
|
type: "select",
|
|
opts: ["S3 (eu-central)", "SFTP", "Download"],
|
|
def: "S3 (eu-central)",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
export const modsByCat = (cat: string) =>
|
|
Object.entries(FLOW_MODULES)
|
|
.filter(([, m]) => m.cat === cat)
|
|
.map(([id, m]) => ({ id, ...m }));
|
|
|
|
export const NODE_W = 202;
|
|
|
|
export function nodeH(m: FlowModule): number {
|
|
return Math.max(58, 36 + Math.max(m.in.length, m.out.length, 1) * 22);
|
|
}
|
|
|
|
export function defConfig(modId: string): Record<string, string | number | boolean> {
|
|
const m = FLOW_MODULES[modId];
|
|
if (!m) return {};
|
|
const c: Record<string, string | number | boolean> = {};
|
|
(m.settings || []).forEach((s) => {
|
|
c[s.k] = s.def;
|
|
});
|
|
return c;
|
|
}
|
|
|
|
export type FlowNode = {
|
|
id: string;
|
|
mod: string;
|
|
x: number;
|
|
y: number;
|
|
config: Record<string, string | number | boolean>;
|
|
};
|
|
|
|
export type FlowEdge = {
|
|
id: string;
|
|
from: [string, number];
|
|
to: [string, number];
|
|
};
|
|
|
|
export function seedFlow(): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
|
const mk = (id: string, mod: string, x: number, y: number): FlowNode => ({
|
|
id,
|
|
mod,
|
|
x,
|
|
y,
|
|
config: defConfig(mod),
|
|
});
|
|
const nodes: FlowNode[] = [
|
|
mk("n1", "scan-complete", 24, 70),
|
|
mk("n2", "sev-gate", 270, 86),
|
|
mk("n3", "create-evidence", 516, 24),
|
|
mk("n4", "notify", 516, 188),
|
|
mk("n6", "schedule", 24, 320),
|
|
mk("n7", "gen-annex", 270, 320),
|
|
mk("n5", "map-control", 516, 320),
|
|
];
|
|
const edges: FlowEdge[] = [
|
|
{ id: "e1", from: ["n1", 0], to: ["n2", 0] },
|
|
{ id: "e2", from: ["n2", 0], to: ["n3", 0] },
|
|
{ id: "e3", from: ["n2", 1], to: ["n4", 0] },
|
|
{ id: "e4", from: ["n6", 0], to: ["n7", 0] },
|
|
{ id: "e5", from: ["n7", 0], to: ["n5", 0] },
|
|
];
|
|
return { nodes, edges };
|
|
}
|
|
|
|
export function portY(node: FlowNode, side: "in" | "out"): number[] {
|
|
const m = FLOW_MODULES[node.mod];
|
|
if (!m) return [];
|
|
const ports = side === "in" ? m.in : m.out;
|
|
const H = nodeH(m);
|
|
if (ports.length === 0) return [];
|
|
return ports.map((_, i) => node.y + (H * (i + 1)) / (ports.length + 1));
|
|
}
|
|
|
|
export function portX(node: FlowNode, side: "in" | "out"): number {
|
|
return side === "in" ? node.x : node.x + NODE_W;
|
|
}
|
|
|
|
export function wirePath(x1: number, y1: number, x2: number, y2: number): string {
|
|
const dx = Math.max(36, Math.abs(x2 - x1) * 0.45);
|
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
}
|