From eedfdcecc8a7fdabd254898e8a6473d434c3db22 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 12 May 2026 17:31:35 +0200 Subject: [PATCH] Render mermaid diagram per risk assessment scope Each scope card now shows a flowchart of its nodes, processes, and threats, with a distinct shape per type: stadium for entities, hexagon for boundaries, rectangle for assets, cylinder for data, and a red hexagon for threats attached via dashed edges to their process target. The Mermaid source is built on the backend and exposed as a new `mermaid` field on RiskAssessmentScope; the frontend just renders it via @probo/ui's MermaidDiagram and shows a copy button + legend. Signed-off-by: Sacha Al Himdani --- .../CreateScenarioInScopeDialog.tsx | 122 +++++++- .../_components/ScopeCard.tsx | 12 + .../_components/ScopeDiagram.tsx | 282 ++++++++++++++++++ .../ui/src/Atoms/Markdown/MermaidDiagram.tsx | 10 +- packages/ui/src/index.ts | 1 + pkg/coredata/risk_assessment_node.go | 39 +++ pkg/coredata/risk_assessment_process.go | 40 +++ pkg/coredata/risk_assessment_threat.go | 40 +++ pkg/riskmanagement/mermaid.go | 157 ++++++++++ .../v1/graphql/risk_assessment.graphql | 2 + .../console/v1/risk_assessment_resolvers.go | 14 + 11 files changed, 714 insertions(+), 5 deletions(-) create mode 100644 apps/console/src/pages/organizations/risk-assessments/_components/ScopeDiagram.tsx create mode 100644 pkg/riskmanagement/mermaid.go diff --git a/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx b/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx index e09cb3aaf..ef7d160df 100644 --- a/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx +++ b/apps/console/src/pages/organizations/risk-assessments/_components/CreateScenarioInScopeDialog.tsx @@ -27,12 +27,15 @@ import { Select, useDialogRef, } from "@probo/ui"; -import { useState } from "react"; +import { Suspense, useState } from "react"; import { useForm } from "react-hook-form"; -import { graphql, useMutation } from "react-relay"; +import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; +import type { CreateScenarioInScopeDialogLinkRiskMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkRiskMutation.graphql"; import type { CreateScenarioInScopeDialogLinkThreatMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkThreatMutation.graphql"; import type { CreateScenarioInScopeDialogMutation } from "#/__generated__/core/CreateScenarioInScopeDialogMutation.graphql"; +import type { CreateScenarioInScopeDialogRisksQuery } from "#/__generated__/core/CreateScenarioInScopeDialogRisksQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; const createScenarioMutation = graphql` mutation CreateScenarioInScopeDialogMutation( @@ -56,11 +59,75 @@ const linkThreatMutation = graphql` $input: LinkRiskAssessmentScenarioThreatInput! ) { linkRiskAssessmentScenarioThreat(input: $input) { - riskAssessmentScenario { id } + riskAssessmentScenario { + id + threats(first: 10) { edges { node { id name } } } + } } } `; +const linkRiskMutation = graphql` + mutation CreateScenarioInScopeDialogLinkRiskMutation( + $input: LinkRiskAssessmentScenarioRiskInput! + ) { + linkRiskAssessmentScenarioRisk(input: $input) { + riskAssessmentScenario { + id + risks(first: 10) { edges { node { id name } } } + } + riskAssessmentScenarioEdge { node { id } } + } + } +`; + +const risksQuery = graphql` + query CreateScenarioInScopeDialogRisksQuery($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + risks(first: 100) { + edges { node { id name } } + } + } + } + } +`; + +function RiskSelector(props: { + selectedRisks: Map; + onSelect: (id: string, name: string) => void; +}) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const data = useLazyLoadQuery( + risksQuery, + { organizationId }, + { fetchPolicy: "store-or-network" }, + ); + const allRisks = data.node?.risks?.edges?.map(e => e.node) ?? []; + const available = allRisks.filter(r => !props.selectedRisks.has(r.id)); + + if (available.length === 0) { + return

{__("No more risks available.")}

; + } + + return ( + + ); +} + export function CreateScenarioInScopeDialog(props: { scopeId: string; threats: { id: string; name: string }[]; @@ -69,8 +136,10 @@ export function CreateScenarioInScopeDialog(props: { const { __ } = useTranslate(); const dialogRef = useDialogRef(); const [selectedThreats, setSelectedThreats] = useState>(new Map()); + const [selectedRisks, setSelectedRisks] = useState>(new Map()); const [createScenario, isCreating] = useMutation(createScenarioMutation); const [linkThreat] = useMutation(linkThreatMutation); + const [linkRisk] = useMutation(linkRiskMutation); const { register, handleSubmit, reset, formState } = useForm({ defaultValues: { name: "", description: "" }, }); @@ -96,8 +165,16 @@ export function CreateScenarioInScopeDialog(props: { }, }); } + for (const riskId of selectedRisks.keys()) { + linkRisk({ + variables: { + input: { riskAssessmentScenarioId: scenarioId, riskId }, + }, + }); + } reset(); setSelectedThreats(new Map()); + setSelectedRisks(new Map()); dialogRef.current?.close(); }, }); @@ -152,6 +229,7 @@ export function CreateScenarioInScopeDialog(props: { )} {availableThreats.length > 0 && (