Add risk snapshots

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-02 18:52:07 +02:00
parent 781026d3e7
commit 16ced9638d
22 changed files with 639 additions and 253 deletions

View File

@@ -91,7 +91,7 @@ export default function MeasureEvidencesTab() {
const { measure } = useOutletContext<{
measure: MeasureEvidencesTabFragment$key & { id: string; name: string };
}>();
const { evidenceId } = useParams<{ evidenceId: string }>();
const { evidenceId, snapshotId } = useParams<{ evidenceId: string; snapshotId?: string }>();
const pagination = usePaginationFragment(evidencesFragment, measure);
const connectionId = pagination.data.evidences.__id;
const evidences =
@@ -101,6 +101,7 @@ export default function MeasureEvidencesTab() {
const evidence = evidences.find((e) => e.id === evidenceId);
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(measure.name + " - " + __("Evidences"));
@@ -124,34 +125,41 @@ export default function MeasureEvidencesTab() {
measureId={measure.id}
organizationId={organizationId}
connectionId={connectionId}
hideActions={isSnapshotMode}
snapshotId={snapshotId}
/>
))}
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
icon={IconPlusLarge}
>
{__("Add evidence")}
</TrButton>
{!isSnapshotMode && (
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
icon={IconPlusLarge}
>
{__("Add evidence")}
</TrButton>
)}
</Tbody>
</SortableTable>
{evidence && (
<EvidencePreviewDialog
key={evidence?.id}
onClose={() =>
navigate(
`/organizations/${organizationId}/measures/${measure.id}/evidences`
)
}
onClose={() => {
const baseUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/measures/${measure.id}/evidences`
: `/organizations/${organizationId}/measures/${measure.id}/evidences`;
navigate(baseUrl);
}}
evidenceId={evidence.id}
filename={evidence.filename}
/>
)}
<CreateEvidenceDialog
ref={dialogRef}
measureId={measure.id}
connectionId={connectionId}
/>
{!isSnapshotMode && (
<CreateEvidenceDialog
ref={dialogRef}
measureId={measure.id}
connectionId={connectionId}
/>
)}
</div>
);
}
@@ -161,6 +169,8 @@ function EvidenceRow(props: {
measureId: string;
organizationId: string;
connectionId: string;
hideActions?: boolean;
snapshotId?: string;
}) {
const evidence = useFragment(evidenceFragment, props.evidenceKey);
const { __, dateFormat } = useTranslate();
@@ -203,6 +213,10 @@ function EvidenceRow(props: {
);
};
const evidenceUrl = props.snapshotId
? `/organizations/${props.organizationId}/snapshots/${props.snapshotId}/risks/measures/${props.measureId}/evidences/${evidence.id}`
: `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`;
return (
<>
{isDownloading && (
@@ -211,30 +225,30 @@ function EvidenceRow(props: {
onClose={() => setIsDownloading(false)}
/>
)}
<Tr
to={`/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`}
>
<Tr to={evidenceUrl}>
<Td>{evidence.filename}</Td>
<Td>{fileType(__, evidence)}</Td>
<Td>{fileSize(__, evidence.size)}</Td>
<Td>{dateFormat(evidence.createdAt)}</Td>
<Td noLink>
<div className="flex gap-2">
<ActionDropdown>
<DropdownItem onClick={() => setIsDownloading(true)}>
<IconArrowInbox size={16} />
{__("Download")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
{!props.hideActions && (
<div className="flex gap-2">
<ActionDropdown>
<DropdownItem onClick={() => setIsDownloading(true)}>
<IconArrowInbox size={16} />
{__("Download")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
)}
</Td>
</Tr>
</>

View File

@@ -17,7 +17,7 @@ import {
} from "@probo/ui";
import { Outlet, useNavigate, useParams } from "react-router";
import { useTranslate } from "@probo/i18n";
import { getTreatment, sprintf } from "@probo/helpers";
import { getTreatment, sprintf, validateSnapshotConsistency } from "@probo/helpers";
import { ConnectionHandler } from "relay-runtime";
import { usePreloadedQuery, type PreloadedQuery } from "react-relay";
import FormRiskDialog from "./FormRiskDialog";
@@ -29,23 +29,27 @@ import {
useDeleteRiskMutation,
} from "/hooks/graph/RiskGraph";
import type { RiskGraphNodeQuery } from "/hooks/graph/__generated__/RiskGraphNodeQuery.graphql";
import { SnapshotBanner } from "/components/SnapshotBanner";
type Props = {
queryRef: PreloadedQuery<RiskGraphNodeQuery>;
};
export default function RiskDetailPage(props: Props) {
const { riskId } = useParams<{ riskId: string }>();
const { riskId, snapshotId } = useParams<{ riskId: string; snapshotId?: string }>();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const isSnapshotMode = Boolean(snapshotId);
if (!riskId) {
throw new Error("Cannot load risk detail page without riskId parameter");
}
const { __ } = useTranslate();
const data = usePreloadedQuery(riskNodeQuery, props.queryRef);
const data = usePreloadedQuery<RiskGraphNodeQuery>(riskNodeQuery, props.queryRef);
const risk = data.node;
validateSnapshotConsistency(risk, snapshotId);
const [deleteRisk] = useDeleteRiskMutation();
usePageTitle(risk.name ?? "Risk detail");
@@ -54,7 +58,8 @@ export default function RiskDetailPage(props: Props) {
const onDelete = () => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
RisksConnectionKey
RisksConnectionKey,
{ filter: { snapshotId: snapshotId || null } }
);
confirm(
() =>
@@ -65,7 +70,10 @@ export default function RiskDetailPage(props: Props) {
connections: [connectionId],
},
onSuccess() {
navigate(`/organizations/${organizationId}/risks`);
const risksUrl = isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
: `/organizations/${organizationId}/risks`;
navigate(risksUrl);
resolve();
},
});
@@ -85,68 +93,75 @@ export default function RiskDetailPage(props: Props) {
const measuresCount = risk.measuresInfo?.totalCount ?? 0;
const controlsCount = risk.controlsInfo?.totalCount ?? 0;
const risksUrl = isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
: `/organizations/${organizationId}/risks`;
const baseTabUrl = isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${riskId}`
: `/organizations/${organizationId}/risks/${riskId}`;
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
{/* Header */}
<div className="flex justify-between items-center mb-4">
<Breadcrumb
items={[
{
label: __("Risks"),
to: `/organizations/${organizationId}/risks`,
to: risksUrl,
},
{
label: __("Risk detail"),
},
]}
/>
<div className="flex gap-2">
<FormRiskDialog
trigger={
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
}
risk={{ id: riskId, ...risk }}
/>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
{!isSnapshotMode && (
<div className="flex gap-2">
<FormRiskDialog
trigger={
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
}
risk={{ id: riskId, ...risk }}
/>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
)}
</div>
<PageHeader title={risk.name} />
<Tabs>
<TabLink
to={`/organizations/${organizationId}/risks/${riskId}/overview`}
>
<TabLink to={`${baseTabUrl}/overview`}>
{__("Overview")}
</TabLink>
<TabLink
to={`/organizations/${organizationId}/risks/${riskId}/measures`}
>
{__("Measures")}
<TabBadge>{measuresCount}</TabBadge>
</TabLink>
<TabLink
to={`/organizations/${organizationId}/risks/${riskId}/documents`}
>
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
<TabLink
to={`/organizations/${organizationId}/risks/${riskId}/controls`}
>
{__("Controls")}
<TabBadge>{controlsCount}</TabBadge>
</TabLink>
{!isSnapshotMode && (
<>
<TabLink to={`${baseTabUrl}/measures`}>
{__("Measures")}
<TabBadge>{measuresCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/documents`}>
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/controls`}>
{__("Controls")}
<TabBadge>{controlsCount}</TabBadge>
</TabLink>
</>
)}
</Tabs>
<Outlet context={{ risk }} />

View File

@@ -27,6 +27,8 @@ import { SortableTable, SortableTh } from "/components/SortableTable";
import type { PreloadedQuery } from "react-relay";
import type { RiskGraphListQuery } from "/hooks/graph/__generated__/RiskGraphListQuery.graphql";
import type { RiskGraphFragment$data } from "/hooks/graph/__generated__/RiskGraphFragment.graphql";
import { useParams } from "react-router";
import { SnapshotBanner } from "/components/SnapshotBanner";
type Props = {
queryRef: PreloadedQuery<RiskGraphListQuery>;
@@ -35,18 +37,28 @@ type Props = {
export default function RisksPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { connectionId, risks, refetch } = useRisksQuery(props.queryRef);
const { connectionId, risks, refetch } = useRisksQuery(props.queryRef, snapshotId);
usePageTitle(__("Risks"));
return (
<div className="space-y-6">
<PageHeader title={__("Risks")}>
<FormRiskDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<PageHeader
title={__("Risks")}
description={__(
"Risks are potential threats to your organization. Manage them by identifying, assessing, and implementing mitigation measures."
)}
>
{!isSnapshotMode && (
<FormRiskDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
)}
</PageHeader>
<div className="grid grid-cols-2 gap-4">
@@ -100,6 +112,8 @@ type RowProps = {
function RiskRow(props: RowProps) {
const { __ } = useTranslate();
const { risk, connectionId, organizationId } = props;
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [deleteRisk] = useDeleteRiskMutation();
const confirm = useConfirm();
const onDelete = () => {
@@ -125,14 +139,21 @@ function RiskRow(props: RowProps) {
);
};
const formDialogRef = useDialogRef();
const riskUrl = isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${risk.id}/overview`
: `/organizations/${organizationId}/risks/${risk.id}/overview`;
return (
<>
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
<Tr to={`/organizations/${organizationId}/risks/${risk.id}`}>
{!isSnapshotMode && (
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
)}
<Tr to={riskUrl}>
<Td>{risk.name}</Td>
<Td>{risk.category}</Td>
<Td>{getTreatment(__, risk.treatment)}</Td>
@@ -143,22 +164,24 @@ function RiskRow(props: RowProps) {
<SeverityBadge score={risk.residualRiskScore} />
</Td>
<Td noLink className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
{!isSnapshotMode && (
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</Td>
</Tr>
</>