From 4ed3f5a067667f8434ec988340a41e40315cc841 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 2 Jan 2026 15:04:11 +0100 Subject: [PATCH] Change state of applicability Signed-off-by: Sacha Al Himdani --- .../SnapshotBannerQuery.graphql.ts | 4 +- .../StateOfApplicabilityControlsField.tsx | 518 ++ ...rolsFieldFrameworkControlsQuery.graphql.ts | 197 + ...ityControlsFieldFrameworksQuery.graphql.ts | 172 + .../LinkedSnapshotsCardFragment.graphql.ts | 4 +- .../LinkedSnapshotsDialogFragment.graphql.ts | 4 +- .../LinkedStatesOfApplicabilityCard.tsx | 248 + .../LinkedStatesOfApplicabilityDialog.tsx | 180 + ...atesOfApplicabilityCardFragment.graphql.ts | 100 + ...tatesOfApplicabilityDialogQuery.graphql.ts | 172 + .../console/src/hooks/graph/FrameworkGraph.ts | 20 + .../src/hooks/graph/ObligationGraph.ts | 6 + .../hooks/graph/StateOfApplicabilityGraph.ts | 235 + .../FrameworkGraphControlNodeQuery.graphql.ts | 331 +- .../FrameworkGraphNodeQuery.graphql.ts | 13 +- .../ObligationGraphCreateMutation.graphql.ts | 18 +- .../ObligationGraphNodeQuery.graphql.ts | 35 +- .../ObligationGraphUpdateMutation.graphql.ts | 18 +- .../SnapshotGraphCreateMutation.graphql.ts | 4 +- .../SnapshotGraphNodeQuery.graphql.ts | 4 +- ...pplicabilityGraphCreateMutation.graphql.ts | 200 + ...pplicabilityGraphDeleteMutation.graphql.ts | 132 + ...fApplicabilityGraphForEditQuery.graphql.ts | 218 + ...teOfApplicabilityGraphNodeQuery.graphql.ts | 343 + ...icabilityGraphPaginatedFragment.graphql.ts | 301 + ...pplicabilityGraphPaginatedQuery.graphql.ts | 318 + ...pplicabilityGraphUpdateMutation.graphql.ts | 171 + .../StateOfApplicabilityListQuery.graphql.ts | 399 ++ apps/console/src/layouts/MainLayout.tsx | 8 + .../frameworks/FrameworkControlPage.tsx | 106 +- .../frameworks/FrameworkDetailPage.tsx | 1 + ...rolPageAttachObligationMutation.graphql.ts | 235 + ...achStateOfApplicabilityMutation.graphql.ts | 237 + ...rolPageDetachObligationMutation.graphql.ts | 133 + ...achStateOfApplicabilityMutation.graphql.ts | 153 + .../FrameworkDetailPageFragment.graphql.ts | 12 +- .../dialogs/FrameworkControlDialog.tsx | 51 +- ...workControlDialogCreateMutation.graphql.ts | 14 +- .../FrameworkControlDialogFragment.graphql.ts | 12 +- ...workControlDialogUpdateMutation.graphql.ts | 14 +- .../obligations/ObligationDetailsPage.tsx | 29 +- .../dialogs/CreateObligationDialog.tsx | 31 +- .../SnapshotsPageFragment.graphql.ts | 4 +- ...napshotFormDialogCreateMutation.graphql.ts | 4 +- .../StateOfApplicabilityDetailPage.tsx | 364 ++ .../StatesOfApplicabilityPage.tsx | 192 + ...abilityDetailPageExportMutation.graphql.ts | 92 + .../CreateStateOfApplicabilityDialog.tsx | 110 + .../dialogs/EditControlDialog.tsx | 176 + .../dialogs/LinkControlDialog.tsx | 385 ++ .../EditControlDialogLinkMutation.graphql.ts | 194 + .../LinkControlDialogLinkMutation.graphql.ts | 194 + .../LinkControlDialogQuery.graphql.ts | 211 + ...LinkControlDialogUnlinkMutation.graphql.ts | 109 + .../tabs/StateOfApplicabilityControlsTab.tsx | 267 + .../tabs/StateOfApplicabilityOverviewTab.tsx | 51 + ...pplicabilityControlsTabFragment.graphql.ts | 200 + ...cabilityControlsTabRefetchQuery.graphql.ts | 247 + ...bilityControlsTabUnlinkMutation.graphql.ts | 109 + apps/console/src/routes.tsx | 4 +- .../src/routes/statesOfApplicabilityRoutes.ts | 70 + e2e/console/control_test.go | 33 +- e2e/console/mapping_test.go | 5 + e2e/console/obligation_test.go | 5 + e2e/console/rbac_test.go | 6 +- e2e/internal/factory/factory.go | 10 +- packages/helpers/src/index.ts | 4 + packages/helpers/src/obligationType.ts | 29 + packages/helpers/src/snapshots.ts | 5 + pkg/authz/permissions.go | 244 +- pkg/coredata/control.go | 135 + pkg/coredata/control_mesure.go | 53 + pkg/coredata/control_obligation.go | 221 + pkg/coredata/entity_type_reg.go | 10 + pkg/coredata/migrations/20260102T134633Z.sql | 161 + pkg/coredata/obligation.go | 141 +- pkg/coredata/obligation_type.go | 64 + pkg/coredata/snapshots_type.go | 7 + pkg/coredata/snapshottable.go | 4 +- pkg/coredata/state_of_applicability.go | 466 ++ .../state_of_applicability_control.go | 607 ++ pkg/coredata/state_of_applicability_filter.go | 61 + .../state_of_applicability_order_field.go | 62 + pkg/docgen/generator.go | 63 +- pkg/docgen/generator_test.go | 4 +- pkg/docgen/soa_template.html | 480 ++ pkg/probo/control_service.go | 139 + ...ta_protection_impact_assessment_service.go | 4 +- pkg/probo/document_service.go | 4 +- pkg/probo/framework_service.go | 12 +- pkg/probo/obligation_service.go | 45 +- pkg/probo/processing_activity_service.go | 4 +- pkg/probo/service.go | 5 + pkg/probo/state_of_applicability_service.go | 589 ++ .../transfer_impact_assessment_service.go | 4 +- pkg/server/api/console/v1/schema.graphql | 250 + pkg/server/api/console/v1/schema/schema.go | 5540 ++++++++++++++++- pkg/server/api/console/v1/types/control.go | 2 + .../v1/types/state_of_applicability.go | 75 + ...ate_of_applicability_control_connection.go | 76 + pkg/server/api/console/v1/types/types.go | 140 + pkg/server/api/console/v1/v1_resolver.go | 439 +- pkg/server/api/mcp/v1/schema.resolvers.go | 4 +- pkg/server/api/mcp/v1/specification.yaml | 23 + pkg/server/api/mcp/v1/types/types.go | 16 +- 105 files changed, 18351 insertions(+), 284 deletions(-) create mode 100644 apps/console/src/components/form/StateOfApplicabilityControlsField.tsx create mode 100644 apps/console/src/components/form/__generated__/StateOfApplicabilityControlsFieldFrameworkControlsQuery.graphql.ts create mode 100644 apps/console/src/components/form/__generated__/StateOfApplicabilityControlsFieldFrameworksQuery.graphql.ts create mode 100644 apps/console/src/components/states-of-applicability/LinkedStatesOfApplicabilityCard.tsx create mode 100644 apps/console/src/components/states-of-applicability/LinkedStatesOfApplicabilityDialog.tsx create mode 100644 apps/console/src/components/states-of-applicability/__generated__/LinkedStatesOfApplicabilityCardFragment.graphql.ts create mode 100644 apps/console/src/components/states-of-applicability/__generated__/LinkedStatesOfApplicabilityDialogQuery.graphql.ts create mode 100644 apps/console/src/hooks/graph/StateOfApplicabilityGraph.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphCreateMutation.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphDeleteMutation.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphForEditQuery.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphNodeQuery.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphPaginatedFragment.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphPaginatedQuery.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityGraphUpdateMutation.graphql.ts create mode 100644 apps/console/src/hooks/graph/__generated__/StateOfApplicabilityListQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/__generated__/FrameworkControlPageAttachObligationMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/__generated__/FrameworkControlPageAttachStateOfApplicabilityMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/__generated__/FrameworkControlPageDetachObligationMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/frameworks/__generated__/FrameworkControlPageDetachStateOfApplicabilityMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/StateOfApplicabilityDetailPage.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/StatesOfApplicabilityPage.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/__generated__/StateOfApplicabilityDetailPageExportMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/CreateStateOfApplicabilityDialog.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/EditControlDialog.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/LinkControlDialog.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/__generated__/EditControlDialogLinkMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/__generated__/LinkControlDialogLinkMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/__generated__/LinkControlDialogQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/dialogs/__generated__/LinkControlDialogUnlinkMutation.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/tabs/StateOfApplicabilityControlsTab.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/tabs/StateOfApplicabilityOverviewTab.tsx create mode 100644 apps/console/src/pages/organizations/states-of-applicability/tabs/__generated__/StateOfApplicabilityControlsTabFragment.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/tabs/__generated__/StateOfApplicabilityControlsTabRefetchQuery.graphql.ts create mode 100644 apps/console/src/pages/organizations/states-of-applicability/tabs/__generated__/StateOfApplicabilityControlsTabUnlinkMutation.graphql.ts create mode 100644 apps/console/src/routes/statesOfApplicabilityRoutes.ts create mode 100644 packages/helpers/src/obligationType.ts create mode 100644 pkg/coredata/control_obligation.go create mode 100644 pkg/coredata/migrations/20260102T134633Z.sql create mode 100644 pkg/coredata/obligation_type.go create mode 100644 pkg/coredata/state_of_applicability.go create mode 100644 pkg/coredata/state_of_applicability_control.go create mode 100644 pkg/coredata/state_of_applicability_filter.go create mode 100644 pkg/coredata/state_of_applicability_order_field.go create mode 100644 pkg/docgen/soa_template.html create mode 100644 pkg/probo/state_of_applicability_service.go create mode 100644 pkg/server/api/console/v1/types/state_of_applicability.go create mode 100644 pkg/server/api/console/v1/types/state_of_applicability_control_connection.go diff --git a/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts b/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts index ca9633707..42d8cfdec 100644 --- a/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts +++ b/apps/console/src/components/__generated__/SnapshotBannerQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -9,7 +9,7 @@ // @ts-nocheck import { ConcreteRequest } from 'relay-runtime'; -export type SnapshotsType = "ASSETS" | "CONTINUAL_IMPROVEMENTS" | "DATA" | "NONCONFORMITIES" | "OBLIGATIONS" | "PROCESSING_ACTIVITIES" | "RISKS" | "VENDORS"; +export type SnapshotsType = "ASSETS" | "CONTINUAL_IMPROVEMENTS" | "DATA" | "NONCONFORMITIES" | "OBLIGATIONS" | "PROCESSING_ACTIVITIES" | "RISKS" | "STATES_OF_APPLICABILITY" | "VENDORS"; export type SnapshotBannerQuery$variables = { snapshotId: string; }; diff --git a/apps/console/src/components/form/StateOfApplicabilityControlsField.tsx b/apps/console/src/components/form/StateOfApplicabilityControlsField.tsx new file mode 100644 index 000000000..e37ff243d --- /dev/null +++ b/apps/console/src/components/form/StateOfApplicabilityControlsField.tsx @@ -0,0 +1,518 @@ +import { useTranslate } from "@probo/i18n"; +import { + Field, + Select, + Option, + Checkbox, + Textarea, + Spinner, + Button, + IconChevronDown, + IconChevronUp, + IconTrashCan, + IconPlusLarge, +} from "@probo/ui"; +import { Suspense, useState, useMemo, useEffect } from "react"; +import { Controller, type Control, type UseFormSetValue, type FieldValues, type Path, type PathValue } from "react-hook-form"; +import { useLazyLoadQuery } from "react-relay"; +import { graphql } from "relay-runtime"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import type { StateOfApplicabilityControlsFieldFrameworksQuery } from "./__generated__/StateOfApplicabilityControlsFieldFrameworksQuery.graphql"; +import type { StateOfApplicabilityControlsFieldFrameworkControlsQuery } from "./__generated__/StateOfApplicabilityControlsFieldFrameworkControlsQuery.graphql"; + +const frameworksQuery = graphql` + query StateOfApplicabilityControlsFieldFrameworksQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + ... on Organization { + frameworks(first: 100) { + edges { + node { + id + name + } + } + } + } + } + } +`; + +const frameworkControlsQuery = graphql` + query StateOfApplicabilityControlsFieldFrameworkControlsQuery( + $frameworkId: ID! + ) { + framework: node(id: $frameworkId) { + ... on Framework { + id + controls(first: 500, orderBy: { field: SECTION_TITLE, direction: ASC }) { + edges { + node { + id + sectionTitle + name + } + } + } + } + } + } +`; + +type ControlSelection = { + controlId: string; + state: "EXCLUDED" | "IMPLEMENTED" | "NOT_IMPLEMENTED"; + exclusionJustification?: string; +}; + +type FrameworkData = { + id: string; + name: string; + controls: Array<{ + id: string; + sectionTitle: string; + name: string; + }>; +}; + +type Props = { + control: Control; + setValue: UseFormSetValue; + name: string; + initialControls?: ControlSelection[]; + initialFrameworkIds?: Set; +}; + +export function StateOfApplicabilityControlsField({ control, setValue, name, initialControls, initialFrameworkIds }: Props) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + + // Initialize form value with initialControls + useEffect(() => { + if (initialControls && initialControls.length > 0) { + setValue(name as Path, initialControls as PathValue>); + } + }, [initialControls, setValue, name]); + + // Initialize framework selection from initialFrameworkIds + const [selectedFrameworkIds, setSelectedFrameworkIds] = useState>(initialFrameworkIds || new Set()); + const [expandedFrameworks, setExpandedFrameworks] = useState>(initialFrameworkIds || new Set()); + const [frameworkDataMap, setFrameworkDataMap] = useState>(new Map()); + const [newFrameworkId, setNewFrameworkId] = useState(""); + + // Update framework selection when initialFrameworkIds changes + useEffect(() => { + if (initialFrameworkIds) { + setSelectedFrameworkIds(initialFrameworkIds); + setExpandedFrameworks(initialFrameworkIds); + } + }, [initialFrameworkIds]); + + const addFramework = (frameworkId: string) => { + if (!frameworkId || selectedFrameworkIds.has(frameworkId)) return; + setSelectedFrameworkIds(new Set([...selectedFrameworkIds, frameworkId])); + setExpandedFrameworks(new Set([...expandedFrameworks, frameworkId])); + setNewFrameworkId(""); + }; + + const removeFramework = (frameworkId: string) => { + const newSet = new Set(selectedFrameworkIds); + newSet.delete(frameworkId); + setSelectedFrameworkIds(newSet); + + const newExpanded = new Set(expandedFrameworks); + newExpanded.delete(frameworkId); + setExpandedFrameworks(newExpanded); + + const newMap = new Map(frameworkDataMap); + newMap.delete(frameworkId); + setFrameworkDataMap(newMap); + }; + + const toggleFramework = (frameworkId: string) => { + const newExpanded = new Set(expandedFrameworks); + if (newExpanded.has(frameworkId)) { + newExpanded.delete(frameworkId); + } else { + newExpanded.add(frameworkId); + } + setExpandedFrameworks(newExpanded); + }; + + return ( +
+
+

+ {__("Select Controls")} +

+ +
+ + }> + + + + + {Array.from(selectedFrameworkIds).map((frameworkId) => ( + + +
+ } + > + toggleFramework(frameworkId)} + onRemove={() => removeFramework(frameworkId)} + control={control} + name={name} + frameworkDataMap={frameworkDataMap} + setFrameworkDataMap={setFrameworkDataMap} + /> + + ))} +
+
+ + ); +} + +function FrameworkSelect({ + organizationId, + selectedFrameworkIds, + value, + onValueChange, + onAdd, +}: { + organizationId: string; + selectedFrameworkIds: Set; + value: string; + onValueChange: (value: string) => void; + onAdd: (frameworkId: string) => void; +}) { + const { __ } = useTranslate(); + const data = useLazyLoadQuery( + frameworksQuery, + { organizationId }, + { fetchPolicy: "network-only" } + ); + const frameworks: Array<{ id: string; name: string }> = + (data?.organization && "frameworks" in data.organization && data.organization.frameworks?.edges + ?.map((edge) => edge.node) + .filter((node): node is NonNullable => node !== null)) || []; + + const availableFrameworks = frameworks.filter( + (framework: { id: string; name: string }) => !selectedFrameworkIds.has(framework.id) + ); + + return ( +
+ + +
+ ); +} + +function FrameworkSection({ + frameworkId, + isExpanded, + onToggle, + onRemove, + control, + name, + frameworkDataMap, + setFrameworkDataMap, +}: { + frameworkId: string; + isExpanded: boolean; + onToggle: () => void; + onRemove: () => void; + control: Control; + name: string; + frameworkDataMap: Map; + setFrameworkDataMap: React.Dispatch>>; +}) { + const { __ } = useTranslate(); + const data = useLazyLoadQuery( + frameworkControlsQuery, + { frameworkId }, + { fetchPolicy: "network-only" } + ); + + const framework = data?.framework && "controls" in data.framework ? data.framework : null; + const frameworkName: string = framework && "name" in framework && typeof framework.name === "string" ? framework.name : ""; + const controls: Array<{ id: string; sectionTitle: string; name: string }> = useMemo( + () => + (framework?.controls?.edges + ?.map((edge) => edge.node) + .filter((node): node is NonNullable => node !== null)) ?? [], + [framework] + ); + + useEffect(() => { + if (framework && !frameworkDataMap.has(frameworkId)) { + setFrameworkDataMap((prev) => { + const newMap = new Map(prev); + newMap.set(frameworkId, { + id: frameworkId, + name: frameworkName, + controls, + }); + return newMap; + }); + } + }, [framework, frameworkId, frameworkName, controls, frameworkDataMap, setFrameworkDataMap]); + + const cachedData = frameworkDataMap.get(frameworkId); + const displayName: string = cachedData?.name || frameworkName; + const displayControls: Array<{ id: string; sectionTitle: string; name: string }> = cachedData?.controls || controls; + + return ( +
+
+ + +
+ + {isExpanded && ( +
+ } + render={({ field }) => { + const selectedControls: ControlSelection[] = (Array.isArray(field.value) ? field.value : []) as ControlSelection[]; + const selectedControlIds = new Set( + selectedControls.map((c: ControlSelection) => c.controlId) + ); + + const toggleControl = (controlId: string) => { + const isSelected = selectedControlIds.has(controlId); + if (isSelected) { + field.onChange( + selectedControls.filter((c: ControlSelection) => c.controlId !== controlId) + ); + } else { + field.onChange([ + ...selectedControls, + { + controlId, + state: "IMPLEMENTED" as const, + exclusionJustification: undefined, + }, + ]); + } + }; + + const updateControlState = ( + controlId: string, + state: "EXCLUDED" | "IMPLEMENTED" | "NOT_IMPLEMENTED" + ) => { + field.onChange( + selectedControls.map((c: ControlSelection) => + c.controlId === controlId ? { ...c, state } : c + ) + ); + }; + + const updateJustification = ( + controlId: string, + exclusionJustification: string + ) => { + field.onChange( + selectedControls.map((c: ControlSelection) => + c.controlId === controlId + ? { ...c, exclusionJustification } + : c + ) + ); + }; + + const getControlState = (controlId: string) => { + const selected = selectedControls.find((c: ControlSelection) => c.controlId === controlId); + return selected?.state || "IMPLEMENTED"; + }; + + const getExclusionJustification = (controlId: string) => { + const selected = selectedControls.find((c: ControlSelection) => c.controlId === controlId); + return selected?.exclusionJustification || ""; + }; + + const selectAll = () => { + const newSelectedControls: ControlSelection[] = [...selectedControls]; + displayControls.forEach((ctrl) => { + if (!selectedControlIds.has(ctrl.id)) { + newSelectedControls.push({ + controlId: ctrl.id, + state: "IMPLEMENTED" as const, + exclusionJustification: undefined, + }); + } + }); + field.onChange(newSelectedControls); + }; + + const deselectAll = () => { + const controlIdsToRemove = new Set(displayControls.map((ctrl) => ctrl.id)); + const newSelectedControls = selectedControls.filter( + (c: ControlSelection) => !controlIdsToRemove.has(c.controlId) + ); + field.onChange(newSelectedControls); + }; + + const allSelected = displayControls.length > 0 && displayControls.every((ctrl) => selectedControlIds.has(ctrl.id)); + + return ( +
+ {displayControls.length > 0 && ( +
+ +
+ )} +
+ {displayControls.length === 0 ? ( +
+ {__("No controls found in this framework")} +
+ ) : ( +
+ {displayControls.map((ctrl) => { + const isSelected = selectedControlIds.has(ctrl.id); + const state = getControlState(ctrl.id); + const exclusionJustification = getExclusionJustification( + ctrl.id + ); + + return ( +
+
+ toggleControl(ctrl.id)} + /> +
+
+ {ctrl.sectionTitle}: {ctrl.name} +
+
+
+ + {isSelected && ( +
+ + + + + {state === "EXCLUDED" || state === "NOT_IMPLEMENTED" && ( + +