From a03aa0a4c43bebd0c812e5427369a9d20ea2acc4 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:52:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal):=20M10.2=20=E2=80=94=20workflows?= =?UTF-8?q?=20node-graph=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/[slug]/workflows/layout.tsx | 21 + src/app/[slug]/workflows/page.tsx | 25 + .../portal/workflows/WorkflowEditor.tsx | 818 ++++++++++++++++++ src/lib/flow-modules.ts | 444 ++++++++++ 4 files changed, 1308 insertions(+) create mode 100644 src/app/[slug]/workflows/layout.tsx create mode 100644 src/app/[slug]/workflows/page.tsx create mode 100644 src/components/portal/workflows/WorkflowEditor.tsx create mode 100644 src/lib/flow-modules.ts diff --git a/src/app/[slug]/workflows/layout.tsx b/src/app/[slug]/workflows/layout.tsx new file mode 100644 index 0000000..fa0c9d9 --- /dev/null +++ b/src/app/[slug]/workflows/layout.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +// Workflows is full-bleed — the editor (palette + canvas + inspector) +// takes the entire content area, so we strip the standard `.content-inner` +// max-width wrapper. The parent `.content` is already `overflow-y: auto`, +// so we force `min-height: 0` here to let the editor's own grid fill. +export default function WorkflowsLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/[slug]/workflows/page.tsx b/src/app/[slug]/workflows/page.tsx new file mode 100644 index 0000000..e40a520 --- /dev/null +++ b/src/app/[slug]/workflows/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from "next/navigation"; +import { getPortalSession } from "@/lib/get-session"; +import { hasOrgRole } from "@/lib/session"; +import { loadTenantForShell } from "@/lib/portal-data"; +import { NotAllowed } from "@/components/portal/NotAllowed"; +import { WorkflowEditor } from "@/components/portal/workflows/WorkflowEditor"; + +export default async function WorkflowsPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const session = await getPortalSession(); + + if (!session) redirect(`/${slug}/dashboard`); + if (!hasOrgRole(session, "IT_ADMIN")) { + return ; + } + + const t = await loadTenantForShell(slug); + if (!t) return null; + + return ; +} diff --git a/src/components/portal/workflows/WorkflowEditor.tsx b/src/components/portal/workflows/WorkflowEditor.tsx new file mode 100644 index 0000000..d9186fa --- /dev/null +++ b/src/components/portal/workflows/WorkflowEditor.tsx @@ -0,0 +1,818 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { + Check, + ChevronDown, + ChevronRight, + Maximize2, + Minus, + Play, + Plus, + Save, + Trash2, + X, +} from "lucide-react"; +import { + defConfig, + FLOW_CATS, + FLOW_MODULES, + KIND_COLOR, + NODE_W, + modsByCat, + nodeH, + portX, + portY, + seedFlow, + wirePath, + type FlowEdge, + type FlowKind, + type FlowNode, + type FlowSetting, +} from "@/lib/flow-modules"; + +type Selection = + | { type: "node"; id: string } + | { type: "edge"; id: string } + | null; + +type DragState = + | { mode: "pan"; sx: number; sy: number; px: number; py: number } + | { mode: "move"; id: string; ox: number; oy: number } + | { mode: "wire"; from: [string, number] } + | { mode: "new"; mod: string } + | null; + +type PendingWire = { + from: [string, number]; + x0: number; + y0: number; + x: number; + y: number; +}; + +type Ghost = { x: number; y: number; mod: string }; + +type Toast = { id: number; msg: string; code?: string }; + +const SEED = seedFlow(); + +export function WorkflowEditor({ frozen }: { frozen: boolean }) { + const [nodes, setNodes] = useState(SEED.nodes); + const [edges, setEdges] = useState(SEED.edges); + const [sel, setSel] = useState({ type: "node", id: "n2" }); + const [pan, setPan] = useState({ x: 22, y: 54 }); + const [zoom, setZoom] = useState(0.78); + const [pending, setPending] = useState(null); + const [ghost, setGhost] = useState(null); + const [active, setActive] = useState(null); + const [running, setRunning] = useState(false); + const [name, setName] = useState("Findings → evidence + notify"); + const [collapsed, setCollapsed] = useState>({}); + const [toasts, setToasts] = useState([]); + + const wrapRef = useRef(null); + const dragRef = useRef(null); + const stateRef = useRef({ pan, zoom }); + stateRef.current = { pan, zoom }; + + const toWorld = useCallback((cx: number, cy: number) => { + const wrap = wrapRef.current; + if (!wrap) return { x: 0, y: 0 }; + const r = wrap.getBoundingClientRect(); + const { pan, zoom } = stateRef.current; + return { x: (cx - r.left - pan.x) / zoom, y: (cy - r.top - pan.y) / zoom }; + }, []); + + const toast = useCallback((msg: string, code?: string) => { + const id = Date.now() + Math.floor(Math.random() * 1000); + setToasts((ts) => [...ts, { id, msg, code }]); + window.setTimeout(() => { + setToasts((ts) => ts.filter((t) => t.id !== id)); + }, 3400); + }, []); + + // Global mouse handlers for drag-pan, node-move, wire-draw, palette-ghost. + useEffect(() => { + const move = (e: MouseEvent) => { + const d = dragRef.current; + if (!d) return; + if (d.mode === "pan") { + setPan({ x: d.px + (e.clientX - d.sx), y: d.py + (e.clientY - d.sy) }); + } else if (d.mode === "move") { + const w = toWorld(e.clientX, e.clientY); + setNodes((ns) => + ns.map((n) => + n.id === d.id + ? { ...n, x: Math.round(w.x - d.ox), y: Math.round(w.y - d.oy) } + : n, + ), + ); + } else if (d.mode === "wire") { + const w = toWorld(e.clientX, e.clientY); + setPending((p) => (p ? { ...p, x: w.x, y: w.y } : p)); + } else if (d.mode === "new") { + setGhost({ x: e.clientX, y: e.clientY, mod: d.mod }); + } + }; + const up = (e: MouseEvent) => { + const d = dragRef.current; + if (d && d.mode === "new") { + const wrap = wrapRef.current; + if (wrap) { + const r = wrap.getBoundingClientRect(); + if ( + e.clientX > r.left && + e.clientX < r.right && + e.clientY > r.top && + e.clientY < r.bottom + ) { + const w = toWorld(e.clientX, e.clientY); + const id = "n" + Date.now().toString(36); + const mod = d.mod; + setNodes((ns) => [ + ...ns, + { + id, + mod, + x: Math.round(w.x - NODE_W / 2), + y: Math.round(w.y - 28), + config: defConfig(mod), + }, + ]); + setSel({ type: "node", id }); + } + } + setGhost(null); + } + if (d && d.mode === "wire") setPending(null); + dragRef.current = null; + }; + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + }; + }, [toWorld]); + + const deleteSel = useCallback(() => { + setSel((s) => { + if (!s) return s; + if (s.type === "node") { + setNodes((ns) => ns.filter((n) => n.id !== s.id)); + setEdges((es) => es.filter((e) => e.from[0] !== s.id && e.to[0] !== s.id)); + } else { + setEdges((es) => es.filter((e) => e.id !== s.id)); + } + return null; + }); + }, []); + + // Delete/Backspace removes the current selection (unless an input has + // focus — we don't want to nuke nodes while someone's typing). + useEffect(() => { + const h = (e: KeyboardEvent) => { + const tag = (document.activeElement as HTMLElement | null)?.tagName; + if ( + (e.key === "Delete" || e.key === "Backspace") && + sel && + tag !== "INPUT" && + tag !== "TEXTAREA" && + tag !== "SELECT" + ) { + e.preventDefault(); + deleteSel(); + } + }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [sel, deleteSel]); + + const startWire = (e: React.MouseEvent, nodeId: string, outIdx: number) => { + e.stopPropagation(); + const node = nodes.find((n) => n.id === nodeId); + if (!node) return; + const ox = portX(node, "out"); + const oy = portY(node, "out")[outIdx]; + dragRef.current = { mode: "wire", from: [nodeId, outIdx] }; + setPending({ from: [nodeId, outIdx], x0: ox, y0: oy, x: ox, y: oy }); + }; + + const endWire = (e: React.MouseEvent, nodeId: string, inIdx: number) => { + e.stopPropagation(); + const d = dragRef.current; + if (d && d.mode === "wire") { + const from = d.from; + if (from[0] !== nodeId) { + setEdges((es) => [ + ...es.filter((ed) => !(ed.to[0] === nodeId && ed.to[1] === inIdx)), + { id: "e" + Date.now().toString(36), from, to: [nodeId, inIdx] }, + ]); + } + setPending(null); + dragRef.current = null; + } + }; + + const testRun = () => { + if (frozen) { + toast("Tenant frozen — re-activate to run", "402 → reactivation.requested"); + return; + } + setRunning(true); + const incoming: Record = {}; + edges.forEach((e) => { + incoming[e.to[0]] = (incoming[e.to[0]] || 0) + 1; + }); + const order: string[] = []; + const seen = new Set(); + let frontier = nodes.filter((n) => !incoming[n.id]).map((n) => n.id); + while (frontier.length) { + const next: string[] = []; + frontier.forEach((id) => { + if (!seen.has(id)) { + seen.add(id); + order.push(id); + edges.filter((e) => e.from[0] === id).forEach((e) => next.push(e.to[0])); + } + }); + frontier = next; + } + nodes.forEach((n) => { + if (!seen.has(n.id)) order.push(n.id); + }); + order.forEach((id, i) => window.setTimeout(() => setActive(id), i * 420)); + window.setTimeout( + () => { + setRunning(false); + setActive(null); + toast( + `Test run complete · ${nodes.length} nodes · 0 errors`, + "workflow.tested", + ); + }, + order.length * 420 + 700, + ); + }; + + const selNode = sel?.type === "node" ? nodes.find((n) => n.id === sel.id) ?? null : null; + const selMod = selNode ? FLOW_MODULES[selNode.mod] : null; + + const updateConfig = (k: string, v: string | number | boolean) => { + if (!selNode) return; + setNodes((ns) => + ns.map((n) => + n.id === selNode.id ? { ...n, config: { ...n.config, [k]: v } } : n, + ), + ); + }; + + const grid: CSSProperties = useMemo( + () => ({ + backgroundPosition: `${pan.x}px ${pan.y}px`, + backgroundSize: `${22 * zoom}px ${22 * zoom}px`, + }), + [pan.x, pan.y, zoom], + ); + + return ( +
+ {/* ---- palette ---- */} + + + {/* ---- canvas ---- */} +
{ + const t = e.target as HTMLElement; + if ( + t === e.currentTarget || + t.classList.contains("flow-grid") || + t.classList.contains("flow-layer") || + t.tagName.toLowerCase() === "svg" + ) { + setSel(null); + dragRef.current = { + mode: "pan", + sx: e.clientX, + sy: e.clientY, + px: pan.x, + py: pan.y, + }; + } + }} + > +
+ + {/* toolbar */} +
+
+ + setName(e.target.value)} + spellCheck={false} + /> + + {nodes.length} nodes · {edges.length} links + +
+
+ + + +
+
+ + {/* zoom controls */} +
+ + {Math.round(zoom * 100)}% + + +
+ +
+ {/* wires */} + + {edges.map((e) => { + const a = nodes.find((n) => n.id === e.from[0]); + const b = nodes.find((n) => n.id === e.to[0]); + if (!a || !b) return null; + const x1 = portX(a, "out"); + const y1 = portY(a, "out")[e.from[1]]; + const x2 = portX(b, "in"); + const y2 = portY(b, "in")[e.to[1]]; + const lit = running && active === e.from[0]; + const selected = + sel?.type === "edge" && sel.id === e.id; + return ( + { + ev.stopPropagation(); + setSel({ type: "edge", id: e.id }); + }} + /> + ); + })} + {pending ? ( + + ) : null} + + + {/* nodes */} + {nodes.map((n) => { + const m = FLOW_MODULES[n.mod]; + if (!m) return null; + const H = nodeH(m); + const insY = portY(n, "in"); + const outsY = portY(n, "out"); + const isSel = sel?.type === "node" && sel.id === n.id; + const firstSetting = m.settings?.[0]; + const firstValue = + firstSetting && n.config[firstSetting.k] != null + ? String(n.config[firstSetting.k]) + : ""; + return ( +
{ + e.stopPropagation(); + setSel({ type: "node", id: n.id }); + const w = toWorld(e.clientX, e.clientY); + dragRef.current = { + mode: "move", + id: n.id, + ox: w.x - n.x, + oy: w.y - n.y, + }; + }} + > +
+ + {m.mono} + + {m.name} +
+
+ {firstValue.length ? firstValue : m.desc} +
+ {m.in.map((lbl, i) => ( +
endWire(e, n.id, i)} + onMouseDown={(e) => e.stopPropagation()} + > + {m.in.length > 1 ? ( + {lbl} + ) : null} +
+ ))} + {m.out.map((lbl, i) => ( +
startWire(e, n.id, i)} + > + {m.out.length > 1 ? ( + {lbl} + ) : null} +
+ ))} +
+ ); + })} +
+
+ + {/* ---- inspector ---- */} + + + {ghost ? ( +
+ + {FLOW_MODULES[ghost.mod]?.mono} + + {FLOW_MODULES[ghost.mod]?.name} +
+ ) : null} + +
+ {toasts.map((t) => ( +
+
+ {t.msg} + {t.code ? {t.code} : null} +
+
+ ))} +
+
+ ); +} + +// One inspector field per setting. Type-discriminated so each branch +// hands `onChange` a concrete value type. +function FlowField({ + s, + value, + onChange, +}: { + s: FlowSetting; + value: string | number | boolean | undefined; + onChange: (v: string | number | boolean) => void; +}) { + if (s.type === "toggle") { + const on = Boolean(value); + return ( +
+ + +
+ ); + } + if (s.type === "select") { + return ( +
+ + +
+ ); + } + if (s.type === "area") { + return ( +
+ +