diff --git a/apps/console/src/pages/FrameworkListPage.tsx b/apps/console/src/pages/FrameworkListPage.tsx index 342e96a8c..455ccb3e0 100644 --- a/apps/console/src/pages/FrameworkListPage.tsx +++ b/apps/console/src/pages/FrameworkListPage.tsx @@ -1,16 +1,28 @@ -import { Suspense, useEffect } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { graphql, PreloadedQuery, usePreloadedQuery, useQueryLoader, + useMutation, } from "react-relay"; import { Card, CardContent } from "@/components/ui/card"; import { Link, useParams } from "react-router"; import type { FrameworkListPageQuery as FrameworkListPageQueryType } from "./__generated__/FrameworkListPageQuery.graphql"; import { Helmet } from "react-helmet-async"; import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; +import { Plus, Upload } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { FrameworkListPageImportFrameworkMutation as FrameworkListPageImportFrameworkMutationType } from "./__generated__/FrameworkListPageImportFrameworkMutation.graphql"; const FrameworkListPageQuery = graphql` query FrameworkListPageQuery($organizationId: ID!) { @@ -40,6 +52,32 @@ const FrameworkListPageQuery = graphql` } `; +const FrameworkListPageImportFrameworkMutation = graphql` + mutation FrameworkListPageImportFrameworkMutation( + $input: ImportFrameworkInput! + ) { + importFramework(input: $input) { + frameworkEdge { + node { + id + name + description + controls { + edges { + node { + id + state + } + } + } + createdAt + updatedAt + } + } + } + } +`; + function FrameworkCard({ title, description, @@ -88,12 +126,60 @@ function FrameworkListPageContent({ }) { const data = usePreloadedQuery( FrameworkListPageQuery, - queryRef, + queryRef ); const { organizationId } = useParams(); const frameworks = data.organization.frameworks?.edges.map((edge) => edge?.node) ?? []; + const [importFramework] = + useMutation( + FrameworkListPageImportFrameworkMutation + ); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const { toast } = useToast(); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + + importFramework({ + variables: { + input: { + organizationId: organizationId!, + file: null, + }, + }, + uploadables: { + "input.file": file, + }, + onCompleted: () => { + setIsUploading(false); + setIsImportDialogOpen(false); + toast({ + title: "Framework imported", + description: "Framework has been imported successfully.", + variant: "default", + }); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, + onError: (error) => { + setIsUploading(false); + toast({ + title: "Error importing framework", + description: error.message, + variant: "destructive", + }); + }, + }); + }; + return ( <> @@ -107,18 +193,54 @@ function FrameworkListPageContent({ Manage your compliance frameworks

- +
+ + + + + + + Import Framework + +
+
+ + +

+ Upload a JSON file containing your framework definition. +

+
+
+
+
+ +
{frameworks.map((framework) => { const validatedControls = framework.controls.edges.filter( - (edge) => edge?.node?.state === "IMPLEMENTED", + (edge) => edge?.node?.state === "IMPLEMENTED" ).length; const totalControls = framework.controls.edges.length; @@ -185,7 +307,7 @@ function FrameworkListPageFallback() { export default function FrameworkListPage() { const [queryRef, loadQuery] = useQueryLoader( - FrameworkListPageQuery, + FrameworkListPageQuery ); const { organizationId } = useParams(); diff --git a/apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts b/apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts new file mode 100644 index 000000000..be869040e --- /dev/null +++ b/apps/console/src/pages/__generated__/FrameworkListPageImportFrameworkMutation.graphql.ts @@ -0,0 +1,202 @@ +/** + * @generated SignedSource<<886db9744d1d937174d0138601e644a5>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type ControlState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED"; +export type ImportFrameworkInput = { + file: any; + organizationId: string; +}; +export type FrameworkListPageImportFrameworkMutation$variables = { + input: ImportFrameworkInput; +}; +export type FrameworkListPageImportFrameworkMutation$data = { + readonly importFramework: { + readonly frameworkEdge: { + readonly node: { + readonly controls: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly id: string; + readonly state: ControlState; + }; + }>; + }; + readonly createdAt: string; + readonly description: string; + readonly id: string; + readonly name: string; + readonly updatedAt: string; + }; + }; + }; +}; +export type FrameworkListPageImportFrameworkMutation = { + response: FrameworkListPageImportFrameworkMutation$data; + variables: FrameworkListPageImportFrameworkMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v2 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "ImportFrameworkPayload", + "kind": "LinkedField", + "name": "importFramework", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "FrameworkEdge", + "kind": "LinkedField", + "name": "frameworkEdge", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Framework", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "description", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "ControlConnection", + "kind": "LinkedField", + "name": "controls", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ControlEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Control", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "updatedAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "FrameworkListPageImportFrameworkMutation", + "selections": (v2/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "FrameworkListPageImportFrameworkMutation", + "selections": (v2/*: any*/) + }, + "params": { + "cacheID": "2ea6f840625bd25b0469103c8f98d3f2", + "id": null, + "metadata": {}, + "name": "FrameworkListPageImportFrameworkMutation", + "operationKind": "mutation", + "text": "mutation FrameworkListPageImportFrameworkMutation(\n $input: ImportFrameworkInput!\n) {\n importFramework(input: $input) {\n frameworkEdge {\n node {\n id\n name\n description\n controls {\n edges {\n node {\n id\n state\n }\n }\n }\n createdAt\n updatedAt\n }\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "cb68b53c21e128e59354c51048f347cc"; + +export default node; diff --git a/pkg/coredata/control.go b/pkg/coredata/control.go index 2a8727ff3..eebb27151 100644 --- a/pkg/coredata/control.go +++ b/pkg/coredata/control.go @@ -40,6 +40,7 @@ type ( CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` Version int `db:"version"` + Standards []string `db:"standards"` } Controls []*Control @@ -76,6 +77,7 @@ SELECT content_ref, created_at, updated_at, + standards, version FROM controls @@ -124,6 +126,7 @@ INSERT INTO content_ref, created_at, updated_at, + standards, version ) VALUES ( @@ -138,6 +141,7 @@ VALUES ( @content_ref, @created_at, @updated_at, + @standards, @version ); ` @@ -155,6 +159,7 @@ VALUES ( "updated_at": c.UpdatedAt, "state": c.State, "importance": c.Importance, + "standards": c.Standards, } _, err := conn.Exec(ctx, q, args) return err @@ -179,6 +184,7 @@ SELECT content_ref, created_at, updated_at, + standards, version FROM controls @@ -237,7 +243,8 @@ RETURNING content_ref, created_at, updated_at, - version + version, + standards ` q = fmt.Sprintf(q, scope.SQLFragment()) diff --git a/pkg/coredata/migrations/20250313T160700Z.sql b/pkg/coredata/migrations/20250313T160700Z.sql new file mode 100644 index 000000000..6737a3f54 --- /dev/null +++ b/pkg/coredata/migrations/20250313T160700Z.sql @@ -0,0 +1,2 @@ +ALTER TABLE controls ADD COLUMN standards TEXT[] DEFAULT '{}' NOT NULL; +ALTER TABLE controls ALTER COLUMN standards DROP DEFAULT; \ No newline at end of file diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index d5c52d70d..8b88f4b70 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -43,6 +43,25 @@ type ( Name *string Description *string } + + ImportFrameworkRequest struct { + Data struct { + Framework struct { + Name string `json:"name"` + ContentRef string `json:"content-ref"` + Description string `json:"description"` + Version string `json:"version"` + Controls []struct { + ContentRef string `json:"content-ref"` + Category string `json:"category"` + Importance coredata.ControlImportance `json:"importance"` + Standards []string `json:"standards"` + Name string `json:"name"` + Description string `json:"description"` + } `json:"controls"` + } `json:"framework"` + } + } ) func (s FrameworkService) Create( @@ -163,3 +182,76 @@ func (s FrameworkService) Delete( }, ) } + +func (s FrameworkService) Import( + ctx context.Context, + organizationID gid.GID, + req ImportFrameworkRequest, +) (*coredata.Framework, error) { + + now := time.Now() + + frameworkID, err := gid.NewGID(organizationID.TenantID(), coredata.FrameworkEntityType) + if err != nil { + return nil, fmt.Errorf("cannot create global id: %w", err) + } + + framework := &coredata.Framework{ + ID: frameworkID, + OrganizationID: organizationID, + Name: req.Data.Framework.Name, + Description: req.Data.Framework.Description, + ContentRef: req.Data.Framework.ContentRef, + CreatedAt: now, + UpdatedAt: now, + } + + importedControls := coredata.Controls{} + for _, control := range req.Data.Framework.Controls { + controlID, err := gid.NewGID(organizationID.TenantID(), coredata.ControlEntityType) + if err != nil { + return nil, fmt.Errorf("cannot create global id: %w", err) + } + + importedControl := &coredata.Control{ + ID: controlID, + FrameworkID: frameworkID, + Category: control.Category, + Importance: coredata.ControlImportance(control.Importance), + Name: control.Name, + Description: control.Description, + State: coredata.ControlStateNotStarted, + ContentRef: control.ContentRef, + CreatedAt: now, + UpdatedAt: now, + Standards: control.Standards, + } + + importedControls = append(importedControls, importedControl) + } + + err = s.svc.pg.WithTx( + ctx, + func(tx pg.Conn) error { + + err := framework.Insert(ctx, tx, s.svc.scope) + if err != nil { + return fmt.Errorf("cannot insert framework: %w", err) + } + + for _, importedControl := range importedControls { + if err := importedControl.Insert(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert control: %w", err) + } + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return framework, nil +} diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 721765a72..16a8bde15 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -6,6 +6,7 @@ package console_v1 import ( "context" + "encoding/json" "fmt" "time" @@ -348,7 +349,21 @@ func (r *mutationResolver) UpdateFramework(ctx context.Context, input types.Upda // ImportFramework is the resolver for the importFramework field. func (r *mutationResolver) ImportFramework(ctx context.Context, input types.ImportFrameworkInput) (*types.ImportFrameworkPayload, error) { - panic(fmt.Errorf("not implemented: ImportFramework - importFramework")) + svc := r.GetTenantServiceIfAuthorized(ctx, input.OrganizationID.TenantID()) + + req := probo.ImportFrameworkRequest{} + if err := json.NewDecoder(input.File.File).Decode(&req.Data); err != nil { + return nil, fmt.Errorf("cannot decode framework: %w", err) + } + + framework, err := svc.Frameworks.Import(ctx, input.OrganizationID, req) + if err != nil { + return nil, fmt.Errorf("cannot import framework: %w", err) + } + + return &types.ImportFrameworkPayload{ + FrameworkEdge: types.NewFrameworkEdge(framework), + }, nil } // CreateControl is the resolver for the createControl field.