Add UI for risk mapping

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-04-01 09:27:05 +02:00
parent bd428cb109
commit 40d281e8d5
11 changed files with 1680 additions and 198 deletions

View File

@@ -24,6 +24,7 @@ import { VendorPage } from "./vendors/VendorPage";
import { MitigationNewPage } from "./mitigations/MitigationNewPage";
import { RiskListPage } from "./risks/RiskListPage";
import { NewRiskPage } from "./risks/NewRiskPage";
import ShowRiskView from "./risks/ShowRiskView";
export function OrganizationsRoutes() {
return (
@@ -57,6 +58,7 @@ export function OrganizationsRoutes() {
/>
<Route path="risks" element={<RiskListPage />} />
<Route path="risks/new" element={<NewRiskPage />} />
<Route path="risks/:riskId" element={<ShowRiskView />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>

View File

@@ -33,6 +33,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ControlViewLinkedMitigationsQuery,
ControlViewLinkedMitigationsQuery$data,
} from "./__generated__/ControlViewLinkedMitigationsQuery.graphql";
import {
ControlViewOrganizationMitigationsQuery,
ControlViewOrganizationMitigationsQuery$data,
} from "./__generated__/ControlViewOrganizationMitigationsQuery.graphql";
const controlViewQuery = graphql`
query ControlViewQuery($controlId: ID!) {
@@ -115,38 +123,6 @@ const deleteMitigationMappingMutation = graphql`
}
`;
// Add type definitions for the GraphQL responses
interface MitigationNode {
id: string;
name: string;
description: string;
category: string;
importance: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
state: "NOT_STARTED" | "IN_PROGRESS" | "IMPLEMENTED" | "NOT_APPLICABLE";
}
interface LinkedMitigationsData {
control?: {
id: string;
mitigations?: {
edges: Array<{
node: MitigationNode;
}>;
};
};
}
interface OrganizationMitigationsData {
organization?: {
id: string;
mitigations?: {
edges: Array<{
node: MitigationNode;
}>;
};
};
}
export function Control({
control,
}: {
@@ -163,9 +139,9 @@ export function Control({
const [isMitigationMappingDialogOpen, setIsMitigationMappingDialogOpen] =
useState(false);
const [linkedMitigationsData, setLinkedMitigationsData] =
useState<LinkedMitigationsData | null>(null);
useState<ControlViewLinkedMitigationsQuery$data | null>(null);
const [organizationMitigationsData, setOrganizationMitigationsData] =
useState<OrganizationMitigationsData | null>(null);
useState<ControlViewOrganizationMitigationsQuery$data | null>(null);
const [mitigationSearchQuery, setMitigationSearchQuery] = useState("");
const [isLoadingMitigations, setIsLoadingMitigations] = useState(false);
const [isLinkingMitigation, setIsLinkingMitigation] = useState(false);
@@ -184,11 +160,15 @@ export function Control({
useEffect(() => {
if (control.id) {
setIsLoadingMitigations(true);
fetchQuery(environment, linkedMitigationsQuery, {
controlId: control.id,
}).subscribe({
fetchQuery<ControlViewLinkedMitigationsQuery>(
environment,
linkedMitigationsQuery,
{
controlId: control.id,
}
).subscribe({
next: (data) => {
setLinkedMitigationsData(data as LinkedMitigationsData);
setLinkedMitigationsData(data);
setIsLoadingMitigations(false);
},
error: (error: Error) => {
@@ -206,19 +186,27 @@ export function Control({
setIsLoadingMitigations(true);
// Fetch all mitigations for the organization
fetchQuery(environment, organizationMitigationsQuery, {
organizationId,
}).subscribe({
fetchQuery<ControlViewOrganizationMitigationsQuery>(
environment,
organizationMitigationsQuery,
{
organizationId,
}
).subscribe({
next: (data) => {
setOrganizationMitigationsData(data as OrganizationMitigationsData);
setOrganizationMitigationsData(data);
},
complete: () => {
// Fetch linked mitigations for this control
fetchQuery(environment, linkedMitigationsQuery, {
controlId: control.id,
}).subscribe({
fetchQuery<ControlViewLinkedMitigationsQuery>(
environment,
linkedMitigationsQuery,
{
controlId: control.id,
}
).subscribe({
next: (data) => {
setLinkedMitigationsData(data as LinkedMitigationsData);
setLinkedMitigationsData(data);
setIsLoadingMitigations(false);
},
error: (error: Error) => {
@@ -337,11 +325,15 @@ export function Control({
}
// Refresh linked mitigations data
fetchQuery(environment, linkedMitigationsQuery, {
controlId: control.id,
}).subscribe({
fetchQuery<ControlViewLinkedMitigationsQuery>(
environment,
linkedMitigationsQuery,
{
controlId: control.id,
}
).subscribe({
next: (data) => {
setLinkedMitigationsData(data as LinkedMitigationsData);
setLinkedMitigationsData(data);
},
error: (error: Error) => {
console.error("Error refreshing linked mitigations:", error);
@@ -394,11 +386,15 @@ export function Control({
}
// Refresh linked mitigations data
fetchQuery(environment, linkedMitigationsQuery, {
controlId: control.id,
}).subscribe({
fetchQuery<ControlViewLinkedMitigationsQuery>(
environment,
linkedMitigationsQuery,
{
controlId: control.id,
}
).subscribe({
next: (data) => {
setLinkedMitigationsData(data as LinkedMitigationsData);
setLinkedMitigationsData(data);
},
error: (error: Error) => {
console.error("Error refreshing linked mitigations:", error);
@@ -589,85 +585,81 @@ export function Control({
</tr>
</thead>
<tbody>
{filteredMitigations().map(
(mitigation: MitigationNode) => {
const isLinked = isMitigationLinked(
mitigation.id
);
return (
<tr
key={mitigation.id}
className="border-b hover:bg-gray-50"
>
<td className="py-3 px-4">
<div className="font-medium">
{mitigation.name}
{filteredMitigations().map((mitigation) => {
const isLinked = isMitigationLinked(mitigation.id);
return (
<tr
key={mitigation.id}
className="border-b hover:bg-gray-50"
>
<td className="py-3 px-4">
<div className="font-medium">
{mitigation.name}
</div>
{mitigation.description && (
<div className="text-xs text-gray-500 line-clamp-1 mt-0.5">
{mitigation.description}
</div>
{mitigation.description && (
<div className="text-xs text-gray-500 line-clamp-1 mt-0.5">
{mitigation.description}
</div>
)}
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getImportanceColor(
mitigation.importance
)} inline-block`}
)}
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getImportanceColor(
mitigation.importance
)} inline-block`}
>
{formatImportance(mitigation.importance)}
</div>
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
mitigation.state
)} inline-block`}
>
{formatState(mitigation.state)}
</div>
</td>
<td className="py-3 px-4 text-right whitespace-nowrap">
{isLinked ? (
<Button
variant="outline"
size="sm"
onClick={() =>
handleUnlinkMitigation(mitigation.id)
}
disabled={isUnlinkingMitigation}
className="text-xs h-7 text-red-500 border-red-200 hover:bg-red-50"
>
{formatImportance(mitigation.importance)}
</div>
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
mitigation.state
)} inline-block`}
{isUnlinkingMitigation ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<X className="w-4 h-4" />
)}
<span className="ml-1">Unlink</span>
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() =>
handleLinkMitigation(mitigation.id)
}
disabled={isLinkingMitigation}
className="text-xs h-7 text-blue-500 border-blue-200 hover:bg-blue-50"
>
{formatState(mitigation.state)}
</div>
</td>
<td className="py-3 px-4 text-right whitespace-nowrap">
{isLinked ? (
<Button
variant="outline"
size="sm"
onClick={() =>
handleUnlinkMitigation(mitigation.id)
}
disabled={isUnlinkingMitigation}
className="text-xs h-7 text-red-500 border-red-200 hover:bg-red-50"
>
{isUnlinkingMitigation ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<X className="w-4 h-4" />
)}
<span className="ml-1">Unlink</span>
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() =>
handleLinkMitigation(mitigation.id)
}
disabled={isLinkingMitigation}
className="text-xs h-7 text-blue-500 border-blue-200 hover:bg-blue-50"
>
{isLinkingMitigation ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<LinkIcon className="w-4 h-4" />
)}
<span className="ml-1">Link</span>
</Button>
)}
</td>
</tr>
);
}
)}
{isLinkingMitigation ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<LinkIcon className="w-4 h-4" />
)}
<span className="ml-1">Link</span>
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
@@ -720,68 +712,66 @@ export function Control({
</tr>
</thead>
<tbody>
{getLinkedMitigations().map(
(mitigation: MitigationNode) => (
<tr
key={mitigation.id}
className="border-b hover:bg-gray-50"
>
<td className="py-3 px-4">
<div className="font-medium">{mitigation.name}</div>
{mitigation.description && (
<div className="text-xs text-gray-500 line-clamp-1 mt-0.5">
{mitigation.description}
</div>
)}
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getImportanceColor(
mitigation.importance
)} inline-block`}
{getLinkedMitigations().map((mitigation) => (
<tr
key={mitigation.id}
className="border-b hover:bg-gray-50"
>
<td className="py-3 px-4">
<div className="font-medium">{mitigation.name}</div>
{mitigation.description && (
<div className="text-xs text-gray-500 line-clamp-1 mt-0.5">
{mitigation.description}
</div>
)}
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getImportanceColor(
mitigation.importance
)} inline-block`}
>
{formatImportance(mitigation.importance)}
</div>
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
mitigation.state
)} inline-block`}
>
{formatState(mitigation.state)}
</div>
</td>
<td className="py-3 px-4 text-right whitespace-nowrap">
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
asChild
className="text-xs h-7"
>
{formatImportance(mitigation.importance)}
</div>
</td>
<td className="py-3 px-4">
<div
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
mitigation.state
)} inline-block`}
<Link
to={`/organizations/${organizationId}/mitigations/${mitigation.id}`}
>
View
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleUnlinkMitigation(mitigation.id)
}
disabled={isUnlinkingMitigation}
className="text-xs h-7 text-red-500 border-red-200 hover:bg-red-50"
>
{formatState(mitigation.state)}
</div>
</td>
<td className="py-3 px-4 text-right whitespace-nowrap">
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
asChild
className="text-xs h-7"
>
<Link
to={`/organizations/${organizationId}/mitigations/${mitigation.id}`}
>
View
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleUnlinkMitigation(mitigation.id)
}
disabled={isUnlinkingMitigation}
className="text-xs h-7 text-red-500 border-red-200 hover:bg-red-50"
>
Unlink
</Button>
</div>
</td>
</tr>
)
)}
Unlink
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>

View File

@@ -318,21 +318,35 @@ function RiskListViewContent({
key={risk.id}
className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted cursor-pointer"
>
<td className="p-4 align-middle font-medium w-1/2">
{risk.name}
<td className="p-0 align-middle font-medium w-1/2">
<Link
to={`/organizations/${organizationId}/risks/${risk.id}`}
className="block p-4 h-full w-full"
>
{risk.name}
</Link>
</td>
<td className="p-4 align-middle w-1/4 whitespace-nowrap">
{floatToProbabilityText(risk.probability)}
<td className="p-0 align-middle w-1/4 whitespace-nowrap">
<Link
to={`/organizations/${organizationId}/risks/${risk.id}`}
className="block p-4 h-full w-full"
>
{floatToProbabilityText(risk.probability)}
</Link>
</td>
<td className="p-4 align-middle w-1/4 whitespace-nowrap">
{floatToImpactText(risk.impact)}
<td className="p-0 align-middle w-1/4 whitespace-nowrap">
<Link
to={`/organizations/${organizationId}/risks/${risk.id}`}
className="block p-4 h-full w-full"
>
{floatToImpactText(risk.impact)}
</Link>
</td>
<td className="p-4 align-middle w-[80px]">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
onClick={() => {
setRiskToDelete({ id: risk.id, name: risk.name });
}}
>

View File

@@ -0,0 +1,29 @@
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 ShowRiskView = lazy(() => import("./ShowRiskView"));
export function ShowRiskViewSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-2">
<div className="h-96 bg-muted animate-pulse rounded-lg" />
</div>
</PageTemplateSkeleton>
);
}
export function NewRiskPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<ShowRiskViewSkeleton />}>
<ErrorBoundaryWithLocation>
<ShowRiskView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,731 @@
import { Suspense, useCallback, useEffect, useState } from "react";
import { useParams, Link } from "react-router";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
useMutation,
fetchQuery,
useRelayEnvironment,
} from "react-relay";
import type { ShowRiskViewQuery } from "./__generated__/ShowRiskViewQuery.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { ShowRiskViewSkeleton } from "./ShowRiskPage";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Plus, Trash2, Search } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ShowRiskViewOrganizationMitigationsQuery,
ShowRiskViewOrganizationMitigationsQuery$data,
} from "./__generated__/ShowRiskViewOrganizationMitigationsQuery.graphql";
const showRiskViewQuery = graphql`
query ShowRiskViewQuery($riskId: ID!) {
node(id: $riskId) {
id
... on Risk {
name
description
probability
impact
createdAt
updatedAt
mitigations(first: 100) @connection(key: "Risk__mitigations") {
edges {
node {
id
name
description
category
importance
createdAt
state
}
}
}
}
}
}
`;
// Add query to fetch all mitigations for the organization
const organizationMitigationsQuery = graphql`
query ShowRiskViewOrganizationMitigationsQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
mitigations(first: 100) @connection(key: "Organization__mitigations") {
edges {
node {
id
name
description
category
importance
state
}
}
}
}
}
}
`;
// Add mutation to create risk-mitigation mapping
const createRiskMappingMutation = graphql`
mutation ShowRiskViewCreateRiskMappingMutation(
$input: CreateRiskMappingInput!
) {
createRiskMapping(input: $input) {
success
risk {
id
mitigations(first: 100) @connection(key: "Risk__mitigations") {
edges {
node {
id
name
description
category
importance
state
}
}
}
}
}
}
`;
// Add mutation to delete risk-mitigation mapping
const deleteRiskMappingMutation = graphql`
mutation ShowRiskViewDeleteRiskMappingMutation(
$input: DeleteRiskMappingInput!
) {
deleteRiskMapping(input: $input) {
success
risk {
id
mitigations(first: 100) @connection(key: "Risk__mitigations") {
edges {
node {
id
name
description
category
importance
state
createdAt
}
}
}
}
}
}
`;
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function getRiskSeverity(probability: number, impact: number) {
const score = probability * impact;
if (score >= 0.75) return { level: "High", class: "bg-red-100 text-red-800" };
if (score >= 0.4)
return { level: "Medium", class: "bg-yellow-100 text-yellow-800" };
return { level: "Low", class: "bg-green-100 text-green-800" };
}
function ShowRiskViewContent({
queryRef,
loadQuery,
}: {
queryRef: PreloadedQuery<ShowRiskViewQuery>;
loadQuery: (variables: { riskId: string }) => void;
}) {
const data = usePreloadedQuery<ShowRiskViewQuery>(
showRiskViewQuery,
queryRef
);
const { toast } = useToast();
const environment = useRelayEnvironment();
const { organizationId } = useParams<{ organizationId: string }>();
// Cast the node to Risk type
const risk = data.node;
const severity = getRiskSeverity(risk.probability!, risk.impact!);
// Add state for mitigation mapping dialog
const [isMitigationDialogOpen, setIsMitigationDialogOpen] = useState(false);
const [organizationMitigationsData, setOrganizationMitigationsData] =
useState<ShowRiskViewOrganizationMitigationsQuery$data | null>(null);
const [mitigationSearchQuery, setMitigationSearchQuery] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [isLoadingMitigations, setIsLoadingMitigations] = useState(false);
const [isLinkingMitigation, setIsLinkingMitigation] = useState(false);
const [isUnlinkingMitigation, setIsUnlinkingMitigation] = useState(false);
const [currentMitigationId, setCurrentMitigationId] = useState<string | null>(
null
);
// Setup mutation hooks
const [commitCreateRiskMapping] = useMutation(createRiskMappingMutation);
const [commitDeleteRiskMapping] = useMutation(deleteRiskMappingMutation);
// Clear filters when dialog closes
useEffect(() => {
if (!isMitigationDialogOpen) {
setMitigationSearchQuery("");
setCategoryFilter(null);
}
}, [isMitigationDialogOpen]);
// Load mitigations data when needed
const loadMitigationsData = useCallback(() => {
if (!organizationId || !risk.id) return;
setIsLoadingMitigations(true);
// Fetch all mitigations for the organization
fetchQuery<ShowRiskViewOrganizationMitigationsQuery>(
environment,
organizationMitigationsQuery,
{
organizationId,
}
).subscribe({
next: (data) => {
setOrganizationMitigationsData(data);
setIsLoadingMitigations(false);
},
error: (error: Error) => {
console.error("Error fetching organization mitigations:", error);
setIsLoadingMitigations(false);
toast({
title: "Error",
description: "Failed to load mitigations.",
variant: "destructive",
});
},
});
}, [risk.id, environment, organizationId, toast]);
// Helper functions
const getMitigations = useCallback(() => {
if (!organizationMitigationsData?.organization?.mitigations?.edges)
return [];
return organizationMitigationsData.organization.mitigations.edges.map(
(edge) => edge.node
);
}, [organizationMitigationsData]);
const getMitigationCategories = useCallback(() => {
const mitigations = getMitigations();
const categories = new Set<string>();
mitigations.forEach((mitigation) => {
if (mitigation.category) {
categories.add(mitigation.category);
}
});
return Array.from(categories).sort();
}, [getMitigations]);
const filteredMitigations = useCallback(() => {
const mitigations = getMitigations();
if (!mitigationSearchQuery && !categoryFilter) return mitigations;
return mitigations.filter((mitigation) => {
// Filter by search query
const matchesSearch =
!mitigationSearchQuery ||
mitigation.name
.toLowerCase()
.includes(mitigationSearchQuery.toLowerCase()) ||
(mitigation.description &&
mitigation.description
.toLowerCase()
.includes(mitigationSearchQuery.toLowerCase()));
// Filter by category
const matchesCategory =
!categoryFilter ||
categoryFilter === "all" ||
mitigation.category === categoryFilter;
return matchesSearch && matchesCategory;
});
}, [getMitigations, mitigationSearchQuery, categoryFilter]);
// Handle linking a mitigation to this risk
const handleLinkMitigation = useCallback(
(
mitigation: NonNullable<
NonNullable<
ShowRiskViewOrganizationMitigationsQuery$data["organization"]
>["mitigations"]
>["edges"][0]["node"]
) => {
if (!risk.id) return;
setIsLinkingMitigation(true);
setCurrentMitigationId(mitigation.id);
commitCreateRiskMapping({
variables: {
input: {
riskId: risk.id,
mitigationId: mitigation.id,
probability: risk.probability,
impact: risk.impact,
},
},
onCompleted: (_, errors) => {
setIsLinkingMitigation(false);
setCurrentMitigationId(null);
if (errors && errors.length > 0) {
console.error("Error linking mitigation:", errors);
toast({
title: "Error",
description: "Failed to link mitigation. Please try again.",
variant: "destructive",
});
return;
}
// Refresh main query data using the exact same pattern as in ControlView.tsx
fetchQuery(environment, showRiskViewQuery, {
riskId: risk.id,
}).subscribe({
next: () => {
// Force reload the view data to reflect changes
loadQuery({ riskId: risk.id });
},
error: (error: Error) => {
console.error("Error refreshing risk data:", error);
},
});
toast({
title: "Success",
description: `Linked mitigation "${mitigation.name}" to this risk.`,
});
},
onError: (error) => {
setIsLinkingMitigation(false);
setCurrentMitigationId(null);
console.error("Error linking mitigation:", error);
toast({
title: "Error",
description: "Failed to link mitigation. Please try again.",
variant: "destructive",
});
},
});
},
[
risk.id,
risk.probability,
risk.impact,
commitCreateRiskMapping,
toast,
environment,
loadQuery,
]
);
// Handle unlinking a mitigation from this risk
const handleUnlinkMitigation = useCallback(
(
mitigation: NonNullable<
NonNullable<
ShowRiskViewOrganizationMitigationsQuery$data["organization"]
>["mitigations"]
>["edges"][0]["node"]
) => {
if (!risk.id) return;
setIsUnlinkingMitigation(true);
setCurrentMitigationId(mitigation.id);
commitDeleteRiskMapping({
variables: {
input: {
riskId: risk.id,
mitigationId: mitigation.id,
},
},
onCompleted: (_, errors) => {
setIsUnlinkingMitigation(false);
setCurrentMitigationId(null);
if (errors && errors.length > 0) {
console.error("Error unlinking mitigation:", errors);
toast({
title: "Error",
description: "Failed to unlink mitigation. Please try again.",
variant: "destructive",
});
return;
}
// Refresh main query data using the exact same pattern as in ControlView.tsx
fetchQuery(environment, showRiskViewQuery, {
riskId: risk.id,
}).subscribe({
next: () => {
// Force reload the view data to reflect changes
loadQuery({ riskId: risk.id });
},
error: (error: Error) => {
console.error("Error refreshing risk data:", error);
},
});
toast({
title: "Success",
description: `Unlinked mitigation "${mitigation.name}" from this risk.`,
});
},
onError: (error) => {
setIsUnlinkingMitigation(false);
setCurrentMitigationId(null);
console.error("Error unlinking mitigation:", error);
toast({
title: "Error",
description: "Failed to unlink mitigation. Please try again.",
variant: "destructive",
});
},
});
},
[risk.id, commitDeleteRiskMapping, toast, environment, loadQuery]
);
return (
<PageTemplate title={risk.name ?? ""} description={risk.description || ""}>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Risk Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500">
Probability
</h3>
<p className="mt-1 text-lg">
{(risk.probability! * 100).toFixed(0)}%
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Impact</h3>
<p className="mt-1 text-lg">
{(risk.impact! * 100).toFixed(0)}%
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Severity</h3>
<p className="mt-1">
<Badge className={severity.class}>{severity.level}</Badge>
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Created</h3>
<p className="mt-1 text-sm">{formatDate(risk.createdAt!)}</p>
</div>
</div>
</CardContent>
</Card>
<Tabs defaultValue="mitigations" className="w-full">
<TabsList>
<TabsTrigger value="mitigations">Mitigations</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="mitigations" className="space-y-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Risk Mitigations</h2>
<Button
onClick={() => {
setIsMitigationDialogOpen(true);
loadMitigationsData();
}}
>
<Plus className="mr-2 h-4 w-4" />
Link Mitigation
</Button>
</div>
{risk.mitigations?.edges?.length &&
risk.mitigations?.edges?.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-full">Mitigation</TableHead>
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{risk.mitigations?.edges.map(({ node: mitigation }) => (
<TableRow key={mitigation.id}>
<TableCell>
<Link
to={`/organizations/${organizationId}/mitigations/${mitigation.id}`}
className="font-medium text-blue-600 hover:underline"
>
{mitigation.name}
</Link>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleUnlinkMitigation(mitigation)}
disabled={isUnlinkingMitigation}
title="Unlink mitigation"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-10 text-gray-500">
<p>No mitigations associated with this risk.</p>
</div>
)}
</TabsContent>
<TabsContent value="details">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500">
Full Description
</h3>
<p className="mt-1">{risk.description}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">
Last Updated
</h3>
<p className="mt-1">{formatDate(risk.updatedAt!)}</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Dialog for linking mitigations */}
<Dialog
open={isMitigationDialogOpen}
onOpenChange={setIsMitigationDialogOpen}
>
<DialogContent className="max-w-3xl">
<DialogHeader>
<div className="flex justify-between items-center">
<div>
<DialogTitle>Link Mitigation to Risk</DialogTitle>
<DialogDescription>
Select a mitigation to link to this risk.
</DialogDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
// Force a reload of the risk data to ensure latest mitigation links
loadQuery({ riskId: risk.id });
// Then load the organization mitigations
loadMitigationsData();
}}
title="Refresh data"
disabled={isLoadingMitigations}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`${isLoadingMitigations ? "animate-spin" : ""}`}
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</Button>
</div>
</DialogHeader>
<div className="flex flex-col space-y-4">
<div className="flex flex-row space-x-2">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search mitigations..."
className="pl-8"
value={mitigationSearchQuery}
onChange={(e) => setMitigationSearchQuery(e.target.value)}
/>
</div>
</div>
<Select
value={categoryFilter || "all"}
onValueChange={(value) =>
setCategoryFilter(value === "all" ? null : value)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{getMitigationCategories().map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-md max-h-96 overflow-y-auto">
{isLoadingMitigations ? (
<div className="p-4 text-center">Loading mitigations...</div>
) : filteredMitigations().length === 0 ? (
<div className="p-4 text-center">No mitigations found.</div>
) : (
<div className="divide-y">
{filteredMitigations().map((mitigation) => {
// For each render, recalculate linked status directly against the current risk data
const isLinked = risk.mitigations?.edges?.some(
(edge) => edge.node.id === mitigation.id
);
const isProcessing =
(isLinkingMitigation &&
currentMitigationId === mitigation.id) ||
(isUnlinkingMitigation &&
currentMitigationId === mitigation.id);
return (
<div
key={mitigation.id}
className="p-4 hover:bg-gray-50"
>
<div className="flex justify-between">
<div>
<h3 className="font-medium">{mitigation.name}</h3>
</div>
<Button
variant={isLinked ? "destructive" : "default"}
size="sm"
disabled={isProcessing}
onClick={() =>
isLinked
? handleUnlinkMitigation(mitigation)
: handleLinkMitigation(mitigation)
}
>
{isUnlinkingMitigation &&
currentMitigationId === mitigation.id
? "Unlinking..."
: isLinkingMitigation &&
currentMitigationId === mitigation.id
? "Linking..."
: isLinked
? "Unlink"
: "Link"}
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMitigationDialogOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</PageTemplate>
);
}
export default function ShowRiskView() {
const { riskId } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<ShowRiskViewQuery>(showRiskViewQuery);
useEffect(() => {
loadQuery({ riskId: riskId! });
}, [loadQuery, riskId]);
if (!queryRef) {
return <ShowRiskViewSkeleton />;
}
return (
<Suspense fallback={<ShowRiskViewSkeleton />}>
<ShowRiskViewContent queryRef={queryRef} loadQuery={loadQuery} />
</Suspense>
);
}

View File

@@ -0,0 +1,95 @@
/**
* @generated SignedSource<<16f4621b72343a04c88df9d0b137c9fd>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateRiskMappingInput = {
impact: number;
mitigationId: string;
probability: number;
riskId: string;
};
export type ShowRiskViewCreateRiskMappingMutation$variables = {
input: CreateRiskMappingInput;
};
export type ShowRiskViewCreateRiskMappingMutation$data = {
readonly createRiskMapping: {
readonly success: boolean;
};
};
export type ShowRiskViewCreateRiskMappingMutation = {
response: ShowRiskViewCreateRiskMappingMutation$data;
variables: ShowRiskViewCreateRiskMappingMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "CreateRiskMappingPayload",
"kind": "LinkedField",
"name": "createRiskMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ShowRiskViewCreateRiskMappingMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ShowRiskViewCreateRiskMappingMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "32dfd9049a8c6c1d404528bac1316346",
"id": null,
"metadata": {},
"name": "ShowRiskViewCreateRiskMappingMutation",
"operationKind": "mutation",
"text": "mutation ShowRiskViewCreateRiskMappingMutation(\n $input: CreateRiskMappingInput!\n) {\n createRiskMapping(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "02f82b14cc6cf7c2e39dad4613e9d191";
export default node;

View File

@@ -0,0 +1,93 @@
/**
* @generated SignedSource<<45a4aca30e16d598cd188f3cbc041a0d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteRiskMappingInput = {
mitigationId: string;
riskId: string;
};
export type ShowRiskViewDeleteRiskMappingMutation$variables = {
input: DeleteRiskMappingInput;
};
export type ShowRiskViewDeleteRiskMappingMutation$data = {
readonly deleteRiskMapping: {
readonly success: boolean;
};
};
export type ShowRiskViewDeleteRiskMappingMutation = {
response: ShowRiskViewDeleteRiskMappingMutation$data;
variables: ShowRiskViewDeleteRiskMappingMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "DeleteRiskMappingPayload",
"kind": "LinkedField",
"name": "deleteRiskMapping",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ShowRiskViewDeleteRiskMappingMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ShowRiskViewDeleteRiskMappingMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "847faec465a30f457c57c5c1524a707d",
"id": null,
"metadata": {},
"name": "ShowRiskViewDeleteRiskMappingMutation",
"operationKind": "mutation",
"text": "mutation ShowRiskViewDeleteRiskMappingMutation(\n $input: DeleteRiskMappingInput!\n) {\n deleteRiskMapping(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "41aa62a4185b1e6061bd7e06abbec85d";
export default node;

View File

@@ -0,0 +1,280 @@
/**
* @generated SignedSource<<771e7ea5d1af4e722dc5790db8abd623>>
* @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 ShowRiskViewOrganizationMitigationsQuery$variables = {
organizationId: string;
};
export type ShowRiskViewOrganizationMitigationsQuery$data = {
readonly organization: {
readonly id: string;
readonly mitigations?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly importance: MitigationImportance;
readonly name: string;
readonly state: MitigationState;
};
}>;
};
};
};
export type ShowRiskViewOrganizationMitigationsQuery = {
response: ShowRiskViewOrganizationMitigationsQuery$data;
variables: ShowRiskViewOrganizationMitigationsQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = [
{
"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": [
(v2/*: 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": "importance",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ShowRiskViewOrganizationMitigationsQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": "mitigations",
"args": null,
"concreteType": "MitigationConnection",
"kind": "LinkedField",
"name": "__Organization__mitigations_connection",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": null
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ShowRiskViewOrganizationMitigationsQuery",
"selections": [
{
"alias": "organization",
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "MitigationConnection",
"kind": "LinkedField",
"name": "mitigations",
"plural": false,
"selections": (v4/*: any*/),
"storageKey": "mitigations(first:100)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "Organization__mitigations",
"kind": "LinkedHandle",
"name": "mitigations"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "ac4f3fc31facab3cb2b9455751a4a528",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"organization",
"mitigations"
]
}
]
},
"name": "ShowRiskViewOrganizationMitigationsQuery",
"operationKind": "query",
"text": "query ShowRiskViewOrganizationMitigationsQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n mitigations(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n importance\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "608bcf7b0e53529673b25ac7762627e2";
export default node;

View File

@@ -0,0 +1,246 @@
/**
* @generated SignedSource<<c8807fd1479464c46122e4ca904713f3>>
* @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 ShowRiskViewQuery$variables = {
riskId: string;
};
export type ShowRiskViewQuery$data = {
readonly node: {
readonly createdAt?: string;
readonly description?: string;
readonly id: string;
readonly impact?: number;
readonly mitigations?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly category: string;
readonly description: string;
readonly id: string;
readonly importance: MitigationImportance;
readonly name: string;
readonly state: MitigationState;
};
}>;
};
readonly name?: string;
readonly probability?: number;
readonly updatedAt?: string;
};
};
export type ShowRiskViewQuery = {
response: ShowRiskViewQuery$data;
variables: ShowRiskViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "riskId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "riskId"
}
],
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 = {
"kind": "InlineFragment",
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "probability",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "impact",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
"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": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
{
"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
}
],
"storageKey": "mitigations(first:100)"
}
],
"type": "Risk",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ShowRiskViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v5/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ShowRiskViewQuery",
"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*/),
(v5/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "1fa87a20a3f945fa7da2f72e5b4fa52f",
"id": null,
"metadata": {},
"name": "ShowRiskViewQuery",
"operationKind": "query",
"text": "query ShowRiskViewQuery(\n $riskId: ID!\n) {\n node(id: $riskId) {\n __typename\n id\n ... on Risk {\n name\n description\n probability\n impact\n createdAt\n updatedAt\n mitigations(first: 100) {\n edges {\n node {\n id\n name\n description\n category\n importance\n state\n }\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "824859cf39078da910b00879c17a2a9d";
export default node;

View File

@@ -0,0 +1 @@
ALTER TABLE risks_mitigations ADD COLUMN tenant_id TEXT NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE risks_mitigations ADD COLUMN created_at TIMESTAMP NOT NULL;