Add mitigation views

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-28 14:19:36 +01:00
parent be7bfb9f25
commit 1867c79aad
33 changed files with 1083 additions and 1507 deletions

View File

@@ -145,9 +145,11 @@ function getNavItems(organizationId?: string): NavItem[] {
: undefined,
},
{
title: "Risk",
title: "Risks",
icon: Shield,
url: organizationId ? `/organizations/${organizationId}/risk` : undefined,
url: organizationId
? `/organizations/${organizationId}/risks`
: undefined,
},
{
title: "Frameworks",

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -9,9 +9,7 @@ import { CreatePeoplePage } from "./people/CreatePeoplePage";
import { PeoplePage } from "./people/PeoplePage";
import { CreateFrameworkPage } from "./frameworks/CreateFrameworkPage";
import { UpdateFrameworkPage } from "./frameworks/UpdateFrameworkPage";
import { CreateMitigationPage } from "./frameworks/mitigations/CreateMitigationPage";
import { MitigationPage } from "./frameworks/mitigations/MitigationPage";
import { UpdateMitigationPage } from "./frameworks/mitigations/UpdateMitigationPage";
import { MitigationPage } from "./mitigations/MitigationPage";
import { VendorPage } from "./vendors/VendorPage";
import { PolicyListPage } from "./policies/PolicyListPage";
import { CreatePolicyPage } from "./policies/CreatePolicyPage";
@@ -19,6 +17,8 @@ import { PolicyPage } from "./policies/PolicyPage";
import { UpdatePolicyPage } from "./policies/UpdatePolicyPage";
import { SettingsPage } from "./SettingsPage";
import { CreateOrganizationPage } from "./CreateOrganizationPage";
import { MitigationListPage } from "./mitigations/MitigationListPage";
import HomePage from "./HomePage";
import NotFoundPage from "../NotFoundPage";
@@ -38,18 +38,13 @@ export function OrganizationsRoutes() {
path="frameworks/:frameworkId/update"
element={<UpdateFrameworkPage />}
/>
<Route path="mitigations" element={<MitigationListPage />} />
<Route path="mitigations/:mitigationId" element={<MitigationPage />} />
{/* <Route path="mitigations/create" element={<CreateMitigationPage />} />
<Route
path="frameworks/:frameworkId/mitigations/create"
element={<CreateMitigationPage />}
/>
<Route
path="frameworks/:frameworkId/mitigations/:mitigationId"
element={<MitigationPage />}
/>
<Route
path="frameworks/:frameworkId/mitigations/:mitigationId/update"
path="mitigations/:mitigationId/update"
element={<UpdateMitigationPage />}
/>
/> */}
<Route path="vendors/:vendorId" element={<VendorPage />} />
<Route path="policies" element={<PolicyListPage />} />
<Route path="policies/create" element={<CreatePolicyPage />} />

View File

@@ -1,33 +0,0 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { ErrorBoundaryWithLocation } from "../../ErrorBoundary";
import { Suspense } from "react";
import { useLocation } from "react-router";
import { lazy } from "@probo/react-lazy";
const CreateMitigationView = lazy(() => import("./CreateMitigationView"));
export function CreateMitigationViewSkeleton() {
return (
<PageTemplateSkeleton
title="Create Mitigation"
description="Create a new mitigation for your framework"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export function CreateMitigationPage() {
const location = useLocation();
return (
<Suspense
key={location.pathname}
fallback={<CreateMitigationViewSkeleton />}
>
<ErrorBoundaryWithLocation>
<CreateMitigationView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -1,266 +0,0 @@
import { Suspense, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphql, useMutation, ConnectionHandler } from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CreateMitigationViewCreateMitigationMutation,
MitigationImportance,
} from "./__generated__/CreateMitigationViewCreateMitigationMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { CreateMitigationViewSkeleton } from "./CreateMitigationPage";
const createMitigationMutation = graphql`
mutation CreateMitigationViewCreateMitigationMutation(
$input: CreateMitigationInput!
$connections: [ID!]!
) {
createMitigation(input: $input) {
mitigationEdge @prependEdge(connections: $connections) {
node {
id
name
description
category
state
}
}
}
}
`;
function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
multiline = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
multiline?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={label} className="text-sm font-medium">
{label}
{required && <span className="text-red-500">*</span>}
</Label>
{helpText && (
<div className="relative flex items-center">
<HelpCircle className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{helpText}</span>
</div>
)}
</div>
{multiline ? (
<Textarea
id={label}
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange(e.target.value)
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500"
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
/>
) : (
<Input
id={label}
type={type}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
className={cn("w-full", required && !value && "border-red-500")}
placeholder={`Enter ${label.toLowerCase()}`}
/>
)}
</div>
);
}
function CreateMitigationViewContent() {
const { organizationId, frameworkId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
importance: "MANDATORY" as MitigationImportance,
});
const [commit, isInFlight] =
useMutation<CreateMitigationViewCreateMitigationMutation>(
createMitigationMutation
);
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description || !formData.category) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const connectionId = ConnectionHandler.getConnectionID(
frameworkId!,
"FrameworkOverviewPage_mitigations"
);
commit({
variables: {
input: {
organizationId: organizationId!,
name: formData.name,
description: formData.description,
category: formData.category,
importance: formData.importance,
},
connections: [connectionId],
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to create mitigation",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Mitigation created successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/mitigations/${data.createMitigation.mitigationEdge.node.id}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to create mitigation",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Mitigation"
description="Create a new mitigation for your framework"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
helpText="The category this mitigation belongs to"
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the mitigation"
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) =>
handleFieldChange("importance", value as MitigationImportance)
}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}`
)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? "Creating..." : "Create Mitigation"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export default function CreateControlView() {
return (
<Suspense fallback={<CreateMitigationViewSkeleton />}>
<CreateMitigationViewContent />
</Suspense>
);
}

View File

@@ -1,33 +0,0 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Suspense } from "react";
import { lazy } from "@probo/react-lazy";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../../ErrorBoundary";
const UpdateMitigationView = lazy(() => import("./UpdateMitigationView"));
export function UpdateMitigationViewSkeleton() {
return (
<PageTemplateSkeleton
title="Update Mitigation"
description="Update the mitigation details"
>
<div className="max-w-2xl aspect-square bg-muted rounded-xl animate-pulse" />
</PageTemplateSkeleton>
);
}
export function UpdateMitigationPage() {
const location = useLocation();
return (
<Suspense
key={location.pathname}
fallback={<UpdateMitigationViewSkeleton />}
>
<ErrorBoundaryWithLocation>
<UpdateMitigationView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -1,353 +0,0 @@
import { Suspense, useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import {
graphql,
useMutation,
usePreloadedQuery,
PreloadedQuery,
useQueryLoader,
} from "react-relay";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
MitigationState,
MitigationImportance,
UpdateMitigationViewUpdateMitigationMutation,
} from "./__generated__/UpdateMitigationViewUpdateMitigationMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { MitigationViewSkeleton } from "./MitigationPage";
const updateMitigationMutation = graphql`
mutation UpdateMitigationViewUpdateMitigationMutation(
$input: UpdateMitigationInput!
) {
updateMitigation(input: $input) {
mitigation {
id
name
description
category
importance
state
}
}
}
`;
const updateMitigationQuery = graphql`
query UpdateMitigationViewQuery($mitigationId: ID!) {
node(id: $mitigationId) {
... on Mitigation {
id
name
description
category
importance
state
}
}
}
`;
function EditableField({
label,
value,
onChange,
type = "text",
helpText,
required,
multiline = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
helpText?: string;
required?: boolean;
multiline?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={label} className="text-sm font-medium">
{label}
{required && <span className="text-red-500">*</span>}
</Label>
{helpText && (
<div className="relative flex items-center">
<HelpCircle className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{helpText}</span>
</div>
)}
</div>
{multiline ? (
<Textarea
id={label}
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange(e.target.value)
}
className={cn(
"w-full resize-none",
required && !value && "border-red-500"
)}
placeholder={`Enter ${label.toLowerCase()}`}
rows={4}
/>
) : (
<Input
id={label}
type={type}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(e.target.value)
}
className={cn("w-full", required && !value && "border-red-500")}
placeholder={`Enter ${label.toLowerCase()}`}
/>
)}
</div>
);
}
function UpdateMitigationViewContent({
queryRef,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryRef: PreloadedQuery<any>;
}) {
const { organizationId, frameworkId, mitigationId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const data = usePreloadedQuery(updateMitigationQuery, queryRef);
const [editedFields, setEditedFields] = useState<Set<string>>(new Set());
const [formData, setFormData] = useState({
name: "",
description: "",
category: "",
state: "",
importance: "",
});
useEffect(() => {
if (data.node) {
setFormData({
name: data.node.name || "",
description: data.node.description || "",
category: data.node.category || "",
state: data.node.state || "",
importance: data.node.importance || "",
});
}
}, [data.node]);
const [commit, isInFlight] =
useMutation<UpdateMitigationViewUpdateMitigationMutation>(
updateMitigationMutation
);
const handleFieldChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setEditedFields((prev) => new Set(prev).add(field));
};
const handleCancel = () => {
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/mitigations/${mitigationId}`
);
};
const hasChanges = editedFields.size > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description || !formData.category) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
const input: {
id: string;
name?: string;
description?: string;
category?: string;
state?: MitigationState;
importance?: MitigationImportance;
} = {
id: mitigationId!,
};
if (editedFields.has("name")) {
input.name = formData.name;
}
if (editedFields.has("description")) {
input.description = formData.description;
}
if (editedFields.has("category")) {
input.category = formData.category;
}
if (editedFields.has("state")) {
input.state = formData.state as MitigationState;
}
if (editedFields.has("importance")) {
input.importance = formData.importance as MitigationImportance;
}
commit({
variables: {
input,
},
onCompleted(data, errors) {
if (errors) {
toast({
title: "Error",
description: errors[0]?.message || "Failed to update mitigation",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Mitigation updated successfully",
});
navigate(
`/organizations/${organizationId}/frameworks/${frameworkId}/mitigations/${mitigationId}`
);
},
onError(error) {
toast({
title: "Error",
description: error.message || "Failed to update mitigation",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Update Mitigation"
description="Update the mitigation details"
>
<Card className="max-w-2xl">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<EditableField
label="Name"
value={formData.name}
onChange={(value) => handleFieldChange("name", value)}
required
/>
<EditableField
label="Description"
value={formData.description}
onChange={(value) => handleFieldChange("description", value)}
required
multiline
helpText="Provide a detailed description of the mitigation"
/>
<EditableField
label="Category"
value={formData.category}
onChange={(value) => handleFieldChange("category", value)}
required
/>
<div className="space-y-2">
<Label htmlFor="importance" className="text-sm font-medium">
Importance
</Label>
<Select
value={formData.importance}
onValueChange={(value) => handleFieldChange("importance", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MANDATORY">Mandatory</SelectItem>
<SelectItem value="PREFERRED">Preferred</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-medium">
State
</Label>
<Select
value={formData.state}
onValueChange={(value) => handleFieldChange("state", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NOT_STARTED">Not Started</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="NOT_APPLICABLE">Not Applicable</SelectItem>
<SelectItem value="IMPLEMENTED">Implemented</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" disabled={isInFlight || !hasChanges}>
{isInFlight ? "Updating..." : "Update Mitigation"}
</Button>
</div>
</form>
</Card>
</PageTemplate>
);
}
export default function UpdateControlView() {
const { mitigationId } = useParams();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [queryRef, loadQuery] = useQueryLoader<any>(updateMitigationQuery);
useEffect(() => {
if (mitigationId) {
loadQuery({ mitigationId });
}
}, [mitigationId, loadQuery]);
if (!queryRef) {
return <MitigationViewSkeleton />;
}
return (
<Suspense fallback={<MitigationViewSkeleton />}>
<UpdateMitigationViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -1,196 +0,0 @@
/**
* @generated SignedSource<<07454812ea72dd51d0b615737e75a2a1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MitigationImportance = "ADVANCED" | "MANDATORY" | "PREFERRED";
export type MitigationState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type CreateMitigationInput = {
category: string;
description: string;
importance: MitigationImportance;
name: string;
organizationId: string;
};
export type CreateMitigationViewCreateMitigationMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateMitigationInput;
};
export type CreateMitigationViewCreateMitigationMutation$data = {
readonly createMitigation: {
readonly mitigationEdge: {
readonly node: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly state: MitigationState;
};
};
};
};
export type CreateMitigationViewCreateMitigationMutation = {
response: CreateMitigationViewCreateMitigationMutation$data;
variables: CreateMitigationViewCreateMitigationMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "MitigationEdge",
"kind": "LinkedField",
"name": "mitigationEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Mitigation",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "CreateMitigationViewCreateMitigationMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMitigationPayload",
"kind": "LinkedField",
"name": "createMitigation",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "CreateMitigationViewCreateMitigationMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMitigationPayload",
"kind": "LinkedField",
"name": "createMitigation",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "mitigationEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "09c15768681bc81e7035ff20e90e81a0",
"id": null,
"metadata": {},
"name": "CreateMitigationViewCreateMitigationMutation",
"operationKind": "mutation",
"text": "mutation CreateMitigationViewCreateMitigationMutation(\n $input: CreateMitigationInput!\n) {\n createMitigation(input: $input) {\n mitigationEdge {\n node {\n id\n name\n description\n category\n state\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "42db1acd8c6e92678c9e6355a77e1eb6";
export default node;

View File

@@ -1,176 +0,0 @@
/**
* @generated SignedSource<<c4f9f399e7a3d6c0e0482af3eccc8492>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MitigationImportance = "ADVANCED" | "MANDATORY" | "PREFERRED";
export type MitigationState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type UpdateMitigationViewQuery$variables = {
mitigationId: string;
};
export type UpdateMitigationViewQuery$data = {
readonly node: {
readonly category?: string;
readonly description?: string;
readonly id?: string;
readonly importance?: MitigationImportance;
readonly name?: string;
readonly state?: MitigationState;
};
};
export type UpdateMitigationViewQuery = {
response: UpdateMitigationViewQuery$data;
variables: UpdateMitigationViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "mitigationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "mitigationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "importance",
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateMitigationViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v7/*: any*/)
],
"type": "Mitigation",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateMitigationViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v6/*: any*/),
(v7/*: any*/)
],
"type": "Mitigation",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "81b47701802e110078ee303a83c19d88",
"id": null,
"metadata": {},
"name": "UpdateMitigationViewQuery",
"operationKind": "query",
"text": "query UpdateMitigationViewQuery(\n $mitigationId: ID!\n) {\n node(id: $mitigationId) {\n __typename\n ... on Mitigation {\n id\n name\n description\n category\n importance\n state\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "126fe89ef3da933cfb7847ff05179832";
export default node;

View File

@@ -1,152 +0,0 @@
/**
* @generated SignedSource<<d6d22035bb11c4fff1db97bac26aa7d1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MitigationImportance = "ADVANCED" | "MANDATORY" | "PREFERRED";
export type MitigationState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type UpdateMitigationInput = {
category?: string | null | undefined;
description?: string | null | undefined;
id: string;
importance?: MitigationImportance | null | undefined;
name?: string | null | undefined;
state?: MitigationState | null | undefined;
};
export type UpdateMitigationViewUpdateMitigationMutation$variables = {
input: UpdateMitigationInput;
};
export type UpdateMitigationViewUpdateMitigationMutation$data = {
readonly updateMitigation: {
readonly mitigation: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly importance: MitigationImportance;
readonly name: string;
readonly state: MitigationState;
};
};
};
export type UpdateMitigationViewUpdateMitigationMutation = {
response: UpdateMitigationViewUpdateMitigationMutation$data;
variables: UpdateMitigationViewUpdateMitigationMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateMitigationPayload",
"kind": "LinkedField",
"name": "updateMitigation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Mitigation",
"kind": "LinkedField",
"name": "mitigation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "importance",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateMitigationViewUpdateMitigationMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateMitigationViewUpdateMitigationMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "4a58a30780331c2d00bab2c3b613635b",
"id": null,
"metadata": {},
"name": "UpdateMitigationViewUpdateMitigationMutation",
"operationKind": "mutation",
"text": "mutation UpdateMitigationViewUpdateMitigationMutation(\n $input: UpdateMitigationInput!\n) {\n updateMitigation(input: $input) {\n mitigation {\n id\n name\n description\n category\n importance\n state\n }\n }\n}\n"
}
};
})();
(node as any).hash = "b8cf81c9342fe42160ee39f572b01b24";
export default node;

View File

@@ -0,0 +1,46 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Suspense } from "react";
import { lazy } from "@probo/react-lazy";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const MitigationListView = lazy(() => import("./MitigationListView"));
export function MitigationListViewSkeleton() {
return (
<PageTemplateSkeleton
title="Mitigations"
description="Mitigations are actions taken to reduce the risk of a risk."
>
<div className="space-y-6">
<div className="rounded-xl border bg-card p-4 space-y-4">
<div className="h-5 w-32 bg-muted animate-pulse rounded" />
<div className="flex gap-2">
<div className="h-10 flex-1 bg-muted animate-pulse rounded" />
<div className="h-10 w-32 bg-muted animate-pulse rounded" />
</div>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-[72px] bg-muted animate-pulse rounded-xl"
/>
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
export function MitigationListPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<MitigationListViewSkeleton />}>
<ErrorBoundaryWithLocation>
<MitigationListView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,514 @@
import { Suspense, useEffect, useState } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { useParams, useNavigate } from "react-router";
import { Badge } from "@/components/ui/badge";
import {
ChevronRight,
ChevronDown,
AlertCircle,
CheckCircle2,
Clock,
X,
} from "lucide-react";
import { PageTemplate } from "@/components/PageTemplate";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MitigationListViewQuery as MitigationListViewQueryType } from "./__generated__/MitigationListViewQuery.graphql";
import { MitigationListViewSkeleton } from "./MitigationListPage";
const mitigationListViewQuery = graphql`
query MitigationListViewQuery($organizationId: ID!, $first: Int) {
organization: node(id: $organizationId) {
id
... on Organization {
mitigations(
first: $first
orderBy: { direction: ASC, field: CREATED_AT }
) {
edges {
node {
id
name
description
category
state
importance
createdAt
updatedAt
}
}
}
}
}
}
`;
interface Mitigation {
id?: string;
name?: string;
description?: string;
state?: string;
category?: string;
importance?: string;
status?: string;
createdAt?: string;
updatedAt?: string;
}
interface Category {
id: string;
name: string;
description: string;
progress: number;
mitigations: Mitigation[];
doneCount: number;
totalCount: number;
}
interface OrganizationData {
organization: {
id: string;
mitigations?: {
edges: Array<{
node: Mitigation;
}>;
};
};
}
function MitigationListContent({
queryRef,
}: {
queryRef: PreloadedQuery<MitigationListViewQueryType>;
}) {
const data = usePreloadedQuery<MitigationListViewQueryType>(
mitigationListViewQuery,
queryRef
) as unknown as OrganizationData;
const navigate = useNavigate();
const { organizationId } = useParams();
// Monitor URL hash for changes and update state accordingly
const [hashValue, setHashValue] = useState(window.location.hash);
// Get the active category from the hash
const hashCategory = hashValue.substring(1)
? decodeURIComponent(hashValue.substring(1))
: "";
// Keep track of manually expanded categories
const [expandedCategories, setExpandedCategories] = useState<string[]>(() => {
return hashCategory ? [hashCategory] : [];
});
// When hash changes, update expanded categories to include the hash category
useEffect(() => {
if (hashCategory && !expandedCategories.includes(hashCategory)) {
setExpandedCategories((prev) => [...prev, hashCategory]);
}
}, [hashCategory, expandedCategories]);
// Listen for hash changes (like when using back button)
useEffect(() => {
const handleHashChange = () => {
setHashValue(window.location.hash);
};
window.addEventListener("hashchange", handleHashChange);
return () => {
window.removeEventListener("hashchange", handleHashChange);
};
}, []);
const mitigations =
data.organization.mitigations?.edges.map((edge) => edge.node) ?? [];
// Map mitigation state to status for the new design
const mapStateToStatus = (state?: string): string => {
if (!state) return "incomplete";
switch (state) {
case "IMPLEMENTED":
return "complete";
case "NOT_APPLICABLE":
return "not-applicable";
case "NOT_STARTED":
return "not-started";
case "IN_PROGRESS":
return "in-progress";
default:
return "incomplete";
}
};
const processedMitigations = mitigations.map((mitigation: Mitigation) => ({
...mitigation,
status: mapStateToStatus(mitigation.state),
}));
// Calculate global progress
const implementedCount = processedMitigations.filter(
(mitigation: Mitigation) => mitigation.status === "complete"
).length;
const notApplicableCount = processedMitigations.filter(
(mitigation: Mitigation) => mitigation.status === "not-applicable"
).length;
const totalMitigations = processedMitigations.length;
// Include not-applicable as effectively "complete" for progress percentage
const effectiveCompletedCount = implementedCount + notApplicableCount;
const globalProgress = totalMitigations
? Math.round((effectiveCompletedCount / totalMitigations) * 100)
: 0;
// Get global status counts
const globalStatusCounts = processedMitigations.reduce(
(acc: Record<string, number>, mitigation: Mitigation) => {
if (mitigation.status) {
acc[mitigation.status] = (acc[mitigation.status] || 0) + 1;
}
return acc;
},
{} as Record<string, number>
);
// Group mitigations by category
const mitigationsByCategory = processedMitigations.reduce(
(acc: Record<string, Mitigation[]>, mitigation: Mitigation) => {
if (!mitigation?.category) return acc;
if (!acc[mitigation.category]) {
acc[mitigation.category] = [];
}
acc[mitigation.category].push(mitigation);
return acc;
},
{} as Record<string, Mitigation[]>
);
// Function to toggle a category's expanded state
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
if (prev.includes(categoryId)) {
return prev.filter((id) => id !== categoryId);
} else {
return [...prev, categoryId];
}
});
};
const categories: Category[] = Object.entries(mitigationsByCategory)
.map(([categoryName, categoryMitigations]: [string, Mitigation[]]) => {
const catImplementedCount = categoryMitigations.filter(
(mitigation) => mitigation.status === "complete"
).length;
const catNotApplicableCount = categoryMitigations.filter(
(mitigation) => mitigation.status === "not-applicable"
).length;
// Consider both "complete" and "not-applicable" as done for category progress
const catDoneCount = catImplementedCount + catNotApplicableCount;
const catTotalCount = categoryMitigations.length;
const progress = catTotalCount
? Math.round((catDoneCount / catTotalCount) * 100)
: 0;
return {
id: categoryName,
name: categoryName,
description: `Mitigations related to ${categoryName.toLowerCase()}`,
progress: progress,
mitigations: categoryMitigations.sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
),
doneCount: catDoneCount,
totalCount: catTotalCount,
};
})
.filter((category) => category.mitigations.length > 0)
.sort((a, b) => a.name.localeCompare(b.name));
const getStatusIcon = (status: string) => {
switch (status) {
case "complete":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "in-progress":
return <Clock className="h-5 w-5 text-blue-500" />;
case "not-started":
return <AlertCircle className="h-5 w-5 text-gray-200" />;
case "incomplete":
return <AlertCircle className="h-5 w-5 text-red-500" />;
case "not-applicable":
return <X className="h-5 w-5 text-gray-300" />;
default:
return null;
}
};
return (
<PageTemplate
title="Mitigations"
description="Mitigations are actions taken to reduce the risk. Add them to track their implementation status."
>
{/* Global Progress Summary */}
<div className="mb-8">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-medium">Mitigation Implementation</h3>
<span className="text-sm text-muted-foreground">
{globalProgress}% complete
</span>
</div>
{/* Progress bar container */}
<div className="w-full h-5 rounded-full overflow-hidden bg-muted mb-2">
{/* Segmented progress bar */}
<div className="flex h-full">
{/* Complete segment */}
{globalStatusCounts.complete > 0 && (
<div
className="bg-green-500 h-full"
style={{
width: `${
(globalStatusCounts.complete / totalMitigations) * 100
}%`,
}}
/>
)}
{/* In-progress segment */}
{globalStatusCounts["in-progress"] > 0 && (
<div
className="bg-blue-500 h-full"
style={{
width: `${
(globalStatusCounts["in-progress"] / totalMitigations) * 100
}%`,
}}
/>
)}
{/* Incomplete segment */}
{globalStatusCounts.incomplete > 0 && (
<div
className="bg-red-500 h-full"
style={{
width: `${
(globalStatusCounts.incomplete / totalMitigations) * 100
}%`,
}}
/>
)}
{/* Not applicable segment */}
{globalStatusCounts["not-applicable"] > 0 && (
<div
className="bg-gray-600 h-full"
style={{
width: `${
(globalStatusCounts["not-applicable"] / totalMitigations) *
100
}%`,
}}
/>
)}
{/* Not started segment */}
{globalStatusCounts["not-started"] > 0 && (
<div
className="bg-gray-200 h-full"
style={{
width: `${
(globalStatusCounts["not-started"] / totalMitigations) * 100
}%`,
}}
/>
)}
</div>
</div>
{/* Status legend */}
<div className="flex flex-wrap items-center gap-4 text-sm">
{globalStatusCounts.complete > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span>Complete ({globalStatusCounts.complete})</span>
</div>
)}
{globalStatusCounts["in-progress"] > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span>In Progress ({globalStatusCounts["in-progress"]})</span>
</div>
)}
{globalStatusCounts.incomplete > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span>Incomplete ({globalStatusCounts.incomplete})</span>
</div>
)}
{globalStatusCounts["not-applicable"] > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="w-3 h-3 rounded-full bg-gray-600"></div>
<span>
Not Applicable ({globalStatusCounts["not-applicable"]})
</span>
</div>
)}
{globalStatusCounts["not-started"] > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="w-3 h-3 rounded-full bg-gray-200"></div>
<span>Not Started ({globalStatusCounts["not-started"]})</span>
</div>
)}
</div>
</div>
{/* Category groups */}
<div className="grid gap-6">
{categories.map((category) => {
const isExpanded = expandedCategories.includes(category.id);
return (
<div
key={category.id}
className="border rounded-lg overflow-hidden"
>
<Card className="border-0 shadow-none">
<CardHeader
className="bg-muted/50 cursor-pointer"
onClick={() => toggleCategory(category.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle>{category.name}</CardTitle>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{category.doneCount} / {category.totalCount}
</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="p-0">
{category.mitigations.length > 0 ? (
<div className="w-full">
<table className="w-full">
<thead>
<tr className="bg-muted/30 text-sm font-medium text-muted-foreground">
<th className="w-24 px-4 py-2 text-left">
Importance
</th>
<th className="w-24 px-4 py-2 text-left">
Status
</th>
<th className="px-4 py-2 text-left">
Mitigation
</th>
</tr>
</thead>
<tbody className="divide-y">
{category.mitigations.map((mitigation) => (
<tr
key={mitigation.id || Math.random().toString()}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => {
if (mitigation?.id) {
// Store this category in the hash
const encoded = encodeURIComponent(
category.id
);
window.location.hash = encoded;
setHashValue("#" + encoded);
// Make sure this category is expanded in the local state
if (
!expandedCategories.includes(category.id)
) {
setExpandedCategories((prev) => [
...prev,
category.id,
]);
}
// Use a small timeout to ensure the hash change is processed
setTimeout(() => {
navigate(
`/organizations/${organizationId}/mitigations/${mitigation.id}`
);
}, 100);
}
}}
>
<td className="w-24 px-4 py-3 align-middle">
<Badge variant="outline" className="text-xs">
{mitigation.importance}
</Badge>
</td>
<td className="w-24 px-4 py-3 align-middle">
<div className="flex items-center justify-center">
{mitigation.status
? getStatusIcon(mitigation.status)
: null}
</div>
</td>
<td className="px-4 py-3 align-middle">
<div className="font-medium">
{mitigation.name}
</div>
{mitigation.description && (
<div className="text-sm text-muted-foreground line-clamp-1">
{mitigation.description}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex items-center justify-center p-6 text-center text-muted-foreground">
No mitigations in this category
</div>
)}
</CardContent>
)}
</Card>
</div>
);
})}
</div>
</PageTemplate>
);
}
export default function MitigationListView() {
const [queryRef, loadQuery] = useQueryLoader<MitigationListViewQueryType>(
mitigationListViewQuery
);
const { organizationId } = useParams();
useEffect(() => {
loadQuery({
organizationId: organizationId!,
first: 250,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <MitigationListViewSkeleton />;
}
return (
<Suspense fallback={<MitigationListViewSkeleton />}>
<MitigationListContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -3,7 +3,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Suspense } from "react";
import { lazy } from "@probo/react-lazy";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../../ErrorBoundary";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const MitigationView = lazy(() => import("./MitigationView"));

View File

@@ -71,18 +71,18 @@ import {
SheetClose,
} from "@/components/ui/sheet";
import type { MitigationViewQuery as MitigationViewQueryType } from "./__generated__/MitigationViewQuery.graphql";
import type { MitigationViewUpdateTaskStateMutation as MitigationViewUpdateTaskStateMutationType } from "./__generated__/MitigationViewUpdateTaskStateMutation.graphql";
import type { MitigationViewCreateTaskMutation as MitigationViewCreateTaskMutationType } from "./__generated__/MitigationViewCreateTaskMutation.graphql";
import type { MitigationViewDeleteTaskMutation as MitigationViewDeleteTaskMutationType } from "./__generated__/MitigationViewDeleteTaskMutation.graphql";
import type { MitigationViewUploadEvidenceMutation as MitigationViewUploadEvidenceMutationType } from "./__generated__/MitigationViewUploadEvidenceMutation.graphql";
import type { MitigationViewDeleteEvidenceMutation as MitigationViewDeleteEvidenceMutationType } from "./__generated__/MitigationViewDeleteEvidenceMutation.graphql";
import type { MitigationViewAssignTaskMutation as MitigationViewAssignTaskMutationType } from "./__generated__/MitigationViewAssignTaskMutation.graphql";
import type { MitigationViewUnassignTaskMutation as MitigationViewUnassignTaskMutationType } from "./__generated__/MitigationViewUnassignTaskMutation.graphql";
import type { MitigationViewOrganizationQuery$data } from "./__generated__/MitigationViewOrganizationQuery.graphql";
import type { MitigationViewUpdateMitigationStateMutation as MitigationViewUpdateMitigationStateMutationType } from "./__generated__/MitigationViewUpdateMitigationStateMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { MitigationViewSkeleton } from "./MitigationPage";
import { MitigationViewUpdateTaskStateMutation as MitigationViewUpdateTaskStateMutationType } from "./__generated__/MitigationViewUpdateTaskStateMutation.graphql";
import { MitigationViewCreateTaskMutation as MitigationViewCreateTaskMutationType } from "./__generated__/MitigationViewCreateTaskMutation.graphql";
import { MitigationViewDeleteTaskMutation as MitigationViewDeleteTaskMutationType } from "./__generated__/MitigationViewDeleteTaskMutation.graphql";
import { MitigationViewUploadEvidenceMutation as MitigationViewUploadEvidenceMutationType } from "./__generated__/MitigationViewUploadEvidenceMutation.graphql";
import { MitigationViewDeleteEvidenceMutation as MitigationViewDeleteEvidenceMutationType } from "./__generated__/MitigationViewDeleteEvidenceMutation.graphql";
import { MitigationViewAssignTaskMutation as MitigationViewAssignTaskMutationType } from "./__generated__/MitigationViewAssignTaskMutation.graphql";
import { MitigationViewUnassignTaskMutation as MitigationViewUnassignTaskMutationType } from "./__generated__/MitigationViewUnassignTaskMutation.graphql";
import { MitigationViewUpdateMitigationStateMutation as MitigationViewUpdateMitigationStateMutationType } from "./__generated__/MitigationViewUpdateMitigationStateMutation.graphql";
import { MitigationViewQuery as MitigationViewQueryType } from "./__generated__/MitigationViewQuery.graphql";
import { MitigationViewOrganizationQuery$data } from "./__generated__/MitigationViewOrganizationQuery.graphql";
// Function to format ISO8601 duration to human-readable format
const formatDuration = (isoDuration: string): string => {

View File

@@ -0,0 +1,242 @@
/**
* @generated SignedSource<<f60cb96210dc7db1b8617d7d4414a3d0>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MitigationImportance = "ADVANCED" | "MANDATORY" | "PREFERRED";
export type MitigationState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED";
export type MitigationListViewQuery$variables = {
first?: number | null | undefined;
organizationId: string;
};
export type MitigationListViewQuery$data = {
readonly organization: {
readonly id: string;
readonly mitigations?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
readonly createdAt: string;
readonly description: string;
readonly id: string;
readonly importance: MitigationImportance;
readonly name: string;
readonly state: MitigationState;
readonly updatedAt: string;
};
}>;
};
};
};
export type MitigationListViewQuery = {
response: MitigationListViewQuery$data;
variables: MitigationListViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
},
v2 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v4 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "first",
"variableName": "first"
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "CREATED_AT"
}
}
],
"concreteType": "MitigationConnection",
"kind": "LinkedField",
"name": "mitigations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MitigationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Mitigation",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: 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,
"kind": "ScalarField",
"name": "category",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "importance",
"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
}
],
"type": "Organization",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "MitigationListViewQuery",
"selections": [
{
"alias": "organization",
"args": (v2/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "MitigationListViewQuery",
"selections": [
{
"alias": "organization",
"args": (v2/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v3/*: any*/),
(v4/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "112f47aec28111c799333f3cd144d3c1",
"id": null,
"metadata": {},
"name": "MitigationListViewQuery",
"operationKind": "query",
"text": "query MitigationListViewQuery(\n $organizationId: ID!\n $first: Int\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n mitigations(first: $first, orderBy: {direction: ASC, field: CREATED_AT}) {\n edges {\n node {\n id\n name\n description\n category\n state\n importance\n createdAt\n updatedAt\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "80e8c9cffc7d63bd3ce97ea90f696e82";
export default node;

View File

@@ -44,15 +44,6 @@ type (
}
Mitigations []*Mitigation
UpdateMitigationParams struct {
ExpectedVersion int
Name *string
Description *string
Category *string
State *MitigationState
Importance *MitigationImportance
}
)
func (c Mitigation) CursorKey(orderBy MitigationOrderField) page.CursorKey {
@@ -223,60 +214,33 @@ func (c *Mitigation) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
params UpdateMitigationParams,
) error {
q := `
UPDATE mitigations SET
name = COALESCE(@name, name),
description = COALESCE(@description, description),
category = COALESCE(@category, category),
state = COALESCE(@state, state),
importance = COALESCE(@importance, importance),
updated_at = @updated_at,
version = version + 1
UPDATE mitigations
SET
name = @name,
description = @description,
category = @category,
state = @state,
importance = @importance,
updated_at = @updated_at
WHERE %s
AND id = @mitigation_id
AND version = @expected_version
RETURNING
id,
organization_id,
category,
name,
description,
importance,
state,
content_ref,
created_at,
updated_at,
standards,
version
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"mitigation_id": c.ID,
"expected_version": params.ExpectedVersion,
"name": params.Name,
"description": params.Description,
"category": params.Category,
"state": params.State,
"importance": params.Importance,
"updated_at": time.Now(),
"mitigation_id": c.ID,
"name": c.Name,
"description": c.Description,
"category": c.Category,
"state": c.State,
"importance": c.Importance,
"updated_at": c.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query mitigations: %w", err)
}
mitigation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Mitigation])
if err != nil {
return fmt.Errorf("cannot collect mitigations: %w", err)
}
*c = mitigation
return nil
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -42,14 +42,6 @@ type (
}
Tasks []*Task
UpdateTaskParams struct {
ExpectedVersion int
Name *string
Description *string
State *TaskState
TimeEstimate *time.Duration
}
)
func (c Task) CursorKey(orderBy TaskOrderField) page.CursorKey {
@@ -210,58 +202,33 @@ func (c *Task) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
params UpdateTaskParams,
) error {
q := `
UPDATE tasks SET
name = COALESCE(@name, name),
description = COALESCE(@description, description),
state = COALESCE(@state, state),
time_estimate = COALESCE(@time_estimate, time_estimate),
updated_at = @updated_at,
version = version + 1
UPDATE tasks
SET
name = @name,
description = @description,
state = @state,
time_estimate = @time_estimate,
updated_at = @updated_at
WHERE %s
AND id = @task_id
AND version = @expected_version
RETURNING
id,
mitigation_id,
name,
description,
state,
time_estimate,
assigned_to,
created_at,
updated_at,
version
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"task_id": c.ID,
"expected_version": params.ExpectedVersion,
"name": params.Name,
"description": params.Description,
"state": params.State,
"time_estimate": params.TimeEstimate,
"updated_at": time.Now(),
"task_id": c.ID,
"name": c.Name,
"description": c.Description,
"state": c.State,
"time_estimate": c.TimeEstimate,
"updated_at": c.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query tasks: %w", err)
}
task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task])
if err != nil {
return fmt.Errorf("cannot collect tasks: %w", err)
}
*c = task
return nil
_, err := conn.Exec(ctx, q, args)
return err
}
func (c *Task) AssignTo(
@@ -387,94 +354,3 @@ WHERE %s
return nil
}
// Helper functions for task management
var (
ErrAssignTaskFailed = fmt.Errorf("failed to assign task")
ErrUnassignTaskFailed = fmt.Errorf("failed to unassign task")
ErrUpdateTaskFailed = fmt.Errorf("failed to update task")
ErrDeleteTaskFailed = fmt.Errorf("failed to delete task")
)
type TaskUpdate struct {
Name *string
Description *string
State *TaskState
TimeEstimate *time.Duration
}
func AssignTask(
ctx context.Context,
conn pg.Conn,
scope Scoper,
taskID gid.GID,
assignedToID gid.GID,
) (*Task, error) {
task := &Task{ID: taskID}
if err := task.LoadByID(ctx, conn, scope, taskID); err != nil {
return nil, ErrAssignTaskFailed
}
if err := task.AssignTo(ctx, conn, scope, assignedToID); err != nil {
return nil, ErrAssignTaskFailed
}
return task, nil
}
func UnassignTask(
ctx context.Context,
conn pg.Conn,
scope Scoper,
taskID gid.GID,
) (*Task, error) {
task := &Task{ID: taskID}
if err := task.LoadByID(ctx, conn, scope, taskID); err != nil {
return nil, ErrUnassignTaskFailed
}
if err := task.Unassign(ctx, conn, scope); err != nil {
return nil, ErrUnassignTaskFailed
}
return task, nil
}
func UpdateTask(
ctx context.Context,
conn pg.Conn,
scope Scoper,
taskID gid.GID,
expectedVersion int,
updates *TaskUpdate,
) (*Task, error) {
task := &Task{ID: taskID}
if err := task.Update(ctx, conn, scope, UpdateTaskParams{
ExpectedVersion: expectedVersion,
Name: updates.Name,
Description: updates.Description,
State: updates.State,
TimeEstimate: updates.TimeEstimate,
}); err != nil {
return nil, ErrUpdateTaskFailed
}
return task, nil
}
func DeleteTask(
ctx context.Context,
conn pg.Conn,
scope Scoper,
taskID gid.GID,
) error {
task := &Task{ID: taskID}
if err := task.Delete(ctx, conn, scope); err != nil {
return ErrDeleteTaskFailed
}
return nil
}

View File

@@ -39,13 +39,12 @@ type (
}
UpdateMitigationRequest struct {
ID gid.GID
ExpectedVersion int
Name *string
Description *string
Category *string
State *coredata.MitigationState
Importance *coredata.MitigationImportance
ID gid.GID
Name *string
Description *string
Category *string
State *coredata.MitigationState
Importance *coredata.MitigationImportance
}
)
@@ -73,22 +72,44 @@ func (s MitigationService) Update(
ctx context.Context,
req UpdateMitigationRequest,
) (*coredata.Mitigation, error) {
params := coredata.UpdateMitigationParams{
ExpectedVersion: req.ExpectedVersion,
Name: req.Name,
Description: req.Description,
Category: req.Category,
State: req.State,
Importance: req.Importance,
}
mitigation := &coredata.Mitigation{ID: req.ID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
return mitigation.Update(ctx, conn, s.svc.scope, params)
})
if err := mitigation.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil {
return fmt.Errorf("cannot load mitigation: %w", err)
}
if req.Name != nil {
mitigation.Name = *req.Name
}
if req.Description != nil {
mitigation.Description = *req.Description
}
if req.Category != nil {
mitigation.Category = *req.Category
}
if req.State != nil {
mitigation.State = *req.State
}
if req.Importance != nil {
mitigation.Importance = *req.Importance
}
mitigation.UpdatedAt = time.Now()
if err := mitigation.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update mitigation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}

View File

@@ -16,7 +16,6 @@ package probo
import (
"context"
"errors"
"fmt"
"time"
@@ -40,12 +39,11 @@ type (
}
UpdateTaskRequest struct {
TaskID gid.GID
ExpectedVersion int
Name *string
Description *string
State *coredata.TaskState
TimeEstimate *time.Duration
TaskID gid.GID
Name *string
Description *string
State *coredata.TaskState
TimeEstimate *time.Duration
}
)
@@ -114,20 +112,15 @@ func (s TaskService) Assign(
taskID gid.GID,
assignedToID gid.GID,
) (*coredata.Task, error) {
task := &coredata.Task{}
task := &coredata.Task{ID: taskID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
var assignErr error
task, assignErr = coredata.AssignTask(ctx, conn, s.svc.scope, taskID, assignedToID)
return assignErr
return task.AssignTo(ctx, conn, s.svc.scope, assignedToID)
},
)
if err != nil {
if errors.Is(err, coredata.ErrAssignTaskFailed) {
return nil, errors.New("failed to assign task, please try again")
}
return nil, err
}
@@ -138,20 +131,15 @@ func (s TaskService) Unassign(
ctx context.Context,
taskID gid.GID,
) (*coredata.Task, error) {
task := &coredata.Task{}
task := &coredata.Task{ID: taskID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
var unassignErr error
task, unassignErr = coredata.UnassignTask(ctx, conn, s.svc.scope, taskID)
return unassignErr
return task.Unassign(ctx, conn, s.svc.scope)
},
)
if err != nil {
if errors.Is(err, coredata.ErrUnassignTaskFailed) {
return nil, errors.New("failed to unassign task, please try again")
}
return nil, err
}
@@ -162,32 +150,41 @@ func (s TaskService) Update(
ctx context.Context,
req UpdateTaskRequest,
) (*coredata.Task, error) {
task := &coredata.Task{}
task := &coredata.Task{ID: req.TaskID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
var updateErr error
task, updateErr = coredata.UpdateTask(
ctx,
conn,
s.svc.scope,
req.TaskID,
req.ExpectedVersion,
&coredata.TaskUpdate{
Name: req.Name,
Description: req.Description,
State: req.State,
TimeEstimate: req.TimeEstimate,
},
)
return updateErr
if err := task.LoadByID(ctx, conn, s.svc.scope, req.TaskID); err != nil {
return fmt.Errorf("cannot load task %q: %w", req.TaskID, err)
}
if req.Name != nil {
task.Name = *req.Name
}
if req.Description != nil {
task.Description = *req.Description
}
if req.State != nil {
task.State = *req.State
}
if req.TimeEstimate != nil {
task.TimeEstimate = req.TimeEstimate
}
task.UpdatedAt = time.Now()
if err := task.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update task: %w", err)
}
return nil
},
)
if err != nil {
if errors.Is(err, coredata.ErrUpdateTaskFailed) {
return nil, errors.New("failed to update task, please try again")
}
return nil, err
}
@@ -198,16 +195,15 @@ func (s TaskService) Delete(
ctx context.Context,
taskID gid.GID,
) error {
task := &coredata.Task{ID: taskID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
return coredata.DeleteTask(ctx, conn, s.svc.scope, taskID)
return task.Delete(ctx, conn, s.svc.scope)
},
)
if err != nil {
if errors.Is(err, coredata.ErrDeleteTaskFailed) {
return errors.New("failed to delete task, please try again")
}
return err
}

View File

@@ -212,7 +212,10 @@ enum MitigationOrderField
@goModel(
model: "github.com/getprobo/probo/pkg/coredata.MitigationOrderField"
) {
NAME
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.MitigationOrderFieldCreatedAt"
)
}
enum TaskOrderField

View File

@@ -2409,7 +2409,10 @@ enum MitigationOrderField
@goModel(
model: "github.com/getprobo/probo/pkg/coredata.MitigationOrderField"
) {
NAME
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.MitigationOrderFieldCreatedAt"
)
}
enum TaskOrderField
@@ -23057,12 +23060,12 @@ var (
func (ec *executionContext) unmarshalNMitigationOrderField2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationOrderField(ctx context.Context, v any) (coredata.MitigationOrderField, error) {
tmp, err := graphql.UnmarshalString(v)
res := coredata.MitigationOrderField(tmp)
res := unmarshalNMitigationOrderField2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationOrderField[tmp]
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNMitigationOrderField2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationOrderField(ctx context.Context, sel ast.SelectionSet, v coredata.MitigationOrderField) graphql.Marshaler {
res := graphql.MarshalString(string(v))
res := graphql.MarshalString(marshalNMitigationOrderField2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationOrderField[v])
if res == graphql.Null {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
@@ -23071,6 +23074,15 @@ func (ec *executionContext) marshalNMitigationOrderField2githubᚗcomᚋgetprobo
return res
}
var (
unmarshalNMitigationOrderField2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationOrderField = map[string]coredata.MitigationOrderField{
"CREATED_AT": coredata.MitigationOrderFieldCreatedAt,
}
marshalNMitigationOrderField2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationOrderField = map[coredata.MitigationOrderField]string{
coredata.MitigationOrderFieldCreatedAt: "CREATED_AT",
}
)
func (ec *executionContext) unmarshalNMitigationState2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationState(ctx context.Context, v any) (coredata.MitigationState, error) {
tmp, err := graphql.UnmarshalString(v)
res := unmarshalNMitigationState2githubᚗcomᚋgetproboᚋproboᚋpkgᚋcoredataᚐMitigationState[tmp]

View File

@@ -372,20 +372,6 @@ func (r *mutationResolver) UpdateFramework(ctx context.Context, input types.Upda
}, nil
}
// DeleteFramework is the resolver for the deleteFramework field.
func (r *mutationResolver) DeleteFramework(ctx context.Context, input types.DeleteFrameworkInput) (*types.DeleteFrameworkPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.FrameworkID.TenantID())
err := svc.Frameworks.Delete(ctx, input.FrameworkID)
if err != nil {
return nil, fmt.Errorf("cannot delete framework: %w", err)
}
return &types.DeleteFrameworkPayload{
DeletedFrameworkID: input.FrameworkID,
}, nil
}
// ImportFramework is the resolver for the importFramework field.
func (r *mutationResolver) ImportFramework(ctx context.Context, input types.ImportFrameworkInput) (*types.ImportFrameworkPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.OrganizationID.TenantID())
@@ -405,6 +391,20 @@ func (r *mutationResolver) ImportFramework(ctx context.Context, input types.Impo
}, nil
}
// DeleteFramework is the resolver for the deleteFramework field.
func (r *mutationResolver) DeleteFramework(ctx context.Context, input types.DeleteFrameworkInput) (*types.DeleteFrameworkPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.FrameworkID.TenantID())
err := svc.Frameworks.Delete(ctx, input.FrameworkID)
if err != nil {
return nil, fmt.Errorf("cannot delete framework: %w", err)
}
return &types.DeleteFrameworkPayload{
DeletedFrameworkID: input.FrameworkID,
}, nil
}
// // CreateMitigation is the resolver for the createMitigation field.
func (r *mutationResolver) CreateMitigation(ctx context.Context, input types.CreateMitigationInput) (*types.CreateMitigationPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.OrganizationID.TenantID())