Add framework import

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-13 16:32:30 +01:00
parent 2f229fc27d
commit 03f621ec8d
6 changed files with 453 additions and 13 deletions

View File

@@ -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<FrameworkListPageQueryType>(
FrameworkListPageQuery,
queryRef,
queryRef
);
const { organizationId } = useParams();
const frameworks =
data.organization.frameworks?.edges.map((edge) => edge?.node) ?? [];
const [importFramework] =
useMutation<FrameworkListPageImportFrameworkMutationType>(
FrameworkListPageImportFrameworkMutation
);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<Helmet>
@@ -107,18 +193,54 @@ function FrameworkListPageContent({
Manage your compliance frameworks
</p>
</div>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
</Button>
<div className="flex gap-2">
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Import Framework
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Framework</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="framework-file">
Upload Framework File
</Label>
<Input
id="framework-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
disabled={isUploading}
accept=".json"
/>
<p className="text-sm text-muted-foreground">
Upload a JSON file containing your framework definition.
</p>
</div>
</div>
</DialogContent>
</Dialog>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
</Button>
</div>
</div>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{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<FrameworkListPageQueryType>(
FrameworkListPageQuery,
FrameworkListPageQuery
);
const { organizationId } = useParams();

View File

@@ -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;

View File

@@ -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())

View File

@@ -0,0 +1,2 @@
ALTER TABLE controls ADD COLUMN standards TEXT[] DEFAULT '{}' NOT NULL;
ALTER TABLE controls ALTER COLUMN standards DROP DEFAULT;

View File

@@ -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
}

View File

@@ -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.