Add risk publish to document system

Replace the old snapshot-based system for risks with the publish
document system, mirroring the prior vendor / processing activity / DPIA
/ TIA migration. Includes the GraphQL mutation, MCP tool, CLI command,
n8n operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering name, description, category, treatment,
owner, inherent and residual scoring, and notes.

The risk register lives as a generated DocumentTypeRegister document on
the organization, reused across publishes (the major version bumps on
every republish). Approvers can be passed in to create a draft pending
approval; otherwise the version is published immediately. The frontend
Risks page exposes a Publish button and a Document link button when the
document exists, and pre-fills the previous default approvers.

Risks was the last remaining snapshot type, so this commit also removes
the entire snapshot system: drop snapshotId from the Risk GraphQL type
and RiskFilter; remove RiskSnapshotter, Risks.Snapshot,
InsertRiskSnapshots, and the SnapshotID/SourceID fields on Risk; delete
Snapshot, ControlSnapshot, SnapshotsType, SnapshotOrderField,
Snapshottable, the SnapshotService, the Snapshot console resolvers and
GraphQL schema, the Snapshot MCP types and operations
(list/get/take/listControlSnapshots), the snapshot CLI (prb snapshot),
the snapshot frontend pages, routes, banner, LinkedSnapshotsCard,
SnapshotGraph, snapshot helpers, and the snapshot n8n resource and
control link/unlink snapshot operations. The snapshot_id columns remain
in the database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Risk backed by a new
risks_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Vendor pattern. The migration command
migrate-risk-snapshots-to-documents uses raw SQL queries instead of the
Go snapshot types, since those are gone.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-29 18:11:19 +02:00
parent 01bc3ac696
commit 553901e4ad
93 changed files with 2384 additions and 5741 deletions

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
formatDate,
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
sprintf,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { IconClock } from "@probo/ui";
import { graphql, useLazyLoadQuery } from "react-relay";
import { useLocation } from "react-router";
import type { SnapshotBannerQuery } from "#/__generated__/core/SnapshotBannerQuery.graphql";
const snapshotQuery = graphql`
query SnapshotBannerQuery($snapshotId: ID!) {
node(id: $snapshotId) {
... on Snapshot {
# eslint-disable-next-line relay/unused-fields
id
name
type
createdAt
}
}
}
`;
const isSnapshotTypeValidForUrl = (type: string, pathname: string) => {
const urlPath = getSnapshotTypeUrlPath(type);
return pathname.includes(urlPath);
};
type Props = {
snapshotId: string;
};
export function SnapshotBanner({ snapshotId }: Props) {
const { __ } = useTranslate();
const location = useLocation();
const data = useLazyLoadQuery<SnapshotBannerQuery>(snapshotQuery, {
snapshotId,
});
const snapshot = data.node;
if (!snapshot) {
return null;
}
if (
snapshot.type
&& !isSnapshotTypeValidForUrl(snapshot.type, location.pathname)
) {
throw new Error("PAGE_NOT_FOUND");
}
return (
<div className="bg-warning rounded-lg p-4 flex items-center gap-3">
<IconClock className="text-warning-600 flex-shrink-0" size={20} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-warning-800">
{__("Snapshot")}
{" "}
{snapshot.name}
</span>
</div>
<p className="text-sm text-warning-700">
{sprintf(
__("You are viewing a %s snapshot from %s"),
getSnapshotTypeLabel(__, snapshot.type).toLocaleLowerCase(),
formatDate(snapshot.createdAt),
)}
</p>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { getSnapshotTypeLabel, snapshotTypes } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Option } from "@probo/ui";
export function SnapshotTypeOptions() {
const { __ } = useTranslate();
return (
<>
{snapshotTypes.map(type => (
<Option key={type} value={type}>
{getSnapshotTypeLabel(__, type)}
</Option>
))}
</>
);
}

View File

@@ -1,232 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
formatDate,
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
sprintf,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
Card,
IconChevronDown,
IconPlusLarge,
IconTrashCan,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
TrButton,
} from "@probo/ui";
import { clsx } from "clsx";
import { useMemo, useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { LinkedSnapshotsCardFragment$key } from "#/__generated__/core/LinkedSnapshotsCardFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { LinkedSnapshotsDialog } from "./LinkedSnapshotsDialog";
const linkedSnapshotFragment = graphql`
fragment LinkedSnapshotsCardFragment on Snapshot {
id
name
description
type
createdAt
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
snapshotId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
snapshots: (LinkedSnapshotsCardFragment$key & { id: string })[];
params: Params;
disabled?: boolean;
connectionId: string;
onAttach: Mutation<Params>;
onDetach: Mutation<Params>;
variant?: "card" | "table";
readOnly?: boolean;
};
export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(4);
const snapshots = useMemo(() => {
return limit ? props.snapshots.slice(0, limit) : props.snapshots;
}, [props.snapshots, limit]);
const showMoreButton = limit !== null && props.snapshots.length > limit;
const variant = props.variant ?? "table";
const onAttach = (snapshotId: string) => {
props.onAttach({
variables: {
input: {
snapshotId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (snapshotId: string) => {
props.onDetach({
variables: {
input: {
snapshotId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
const colSpanTable = props.readOnly ? 4 : 5;
const colSpanCard = props.readOnly ? 3 : 4;
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Snapshots")}</div>
{!props.readOnly && (
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link snapshot")}
</Button>
</LinkedSnapshotsDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
{variant === "table" && <Th>{__("Description")}</Th>}
<Th>{__("Created")}</Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{snapshots.length === 0 && (
<Tr>
<Td
colSpan={variant === "table" ? colSpanTable : colSpanCard}
className="text-center text-txt-secondary"
>
{__("No snapshots linked")}
</Td>
</Tr>
)}
{snapshots.map(snapshot => (
<SnapshotRow
key={snapshot.id}
snapshot={snapshot}
onClick={onDetach}
variant={variant}
readOnly={props.readOnly}
/>
))}
{variant === "table" && !props.readOnly && (
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={colSpanTable} icon={IconPlusLarge}>
{__("Link snapshot")}
</TrButton>
</LinkedSnapshotsDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.snapshots.length - limit)}
</Button>
)}
</Wrapper>
);
}
function SnapshotRow(props: {
snapshot: LinkedSnapshotsCardFragment$key & { id: string };
onClick: (snapshotId: string) => void;
variant: "card" | "table";
readOnly?: boolean;
}) {
const snapshot = useFragment(linkedSnapshotFragment, props.snapshot);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const urlPath = getSnapshotTypeUrlPath(snapshot.type);
const snapshotUrl = `/organizations/${organizationId}/snapshots/${snapshot.id}${urlPath}`;
return (
<Tr to={snapshotUrl}>
<Td className="font-medium">{snapshot.name}</Td>
<Td>
<Badge variant="neutral">
{getSnapshotTypeLabel(__, snapshot.type)}
</Badge>
</Td>
{props.variant === "table" && (
<Td className="text-txt-secondary">
{snapshot.description || __("No description")}
</Td>
)}
<Td className="text-txt-tertiary">{formatDate(snapshot.createdAt)}</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(snapshot.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -1,212 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatDate, getSnapshotTypeLabel } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import { type ReactNode, Suspense, useMemo, useState } from "react";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type {
LinkedSnapshotsDialogFragment$data,
LinkedSnapshotsDialogFragment$key,
} from "#/__generated__/core/LinkedSnapshotsDialogFragment.graphql";
import type { LinkedSnapshotsDialogQuery } from "#/__generated__/core/LinkedSnapshotsDialogQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
const snapshotsQuery = graphql`
query LinkedSnapshotsDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
...LinkedSnapshotsDialogFragment
}
}
}
`;
const snapshotsFragment = graphql`
fragment LinkedSnapshotsDialogFragment on Organization
@refetchable(queryName: "LinkedSnapshotsDialogQuery_fragment")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
order: { type: "SnapshotOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
snapshots(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "LinkedSnapshotsDialogQuery_snapshots") {
edges {
node {
id
name
description
type
createdAt
}
}
}
}
`;
type Props = {
children: ReactNode;
disabled?: boolean;
linkedSnapshots?: { id: string }[];
onLink: (snapshotId: string) => void;
onUnlink: (snapshotId: string) => void;
};
export function LinkedSnapshotsDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link snapshots")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedSnapshotsDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedSnapshotsDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const query = useLazyLoadQuery<LinkedSnapshotsDialogQuery>(snapshotsQuery, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
LinkedSnapshotsDialogQuery,
LinkedSnapshotsDialogFragment$key
>(snapshotsFragment, query.organization);
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const snapshots = useMemo(
() => data.snapshots?.edges?.map(edge => edge.node) ?? [],
[data.snapshots],
);
const linkedIds = useMemo(() => {
return new Set(props.linkedSnapshots?.map(s => s.id) ?? []);
}, [props.linkedSnapshots]);
const filteredSnapshots = useMemo(() => {
return snapshots.filter(snapshot =>
snapshot.name.toLowerCase().includes(search.toLowerCase()),
);
}, [snapshots, search]);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search snapshots...")}
onValueChange={setSearch}
/>
</div>
<div className="divide-y divide-border-low">
{filteredSnapshots.map(snapshot => (
<SnapshotRow
key={snapshot.id}
snapshot={snapshot}
linkedSnapshots={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
type Snapshot = NodeOf<LinkedSnapshotsDialogFragment$data["snapshots"]>;
type RowProps = {
snapshot: Snapshot;
linkedSnapshots: Set<string>;
disabled?: boolean;
onLink: (snapshotId: string) => void;
onUnlink: (snapshotId: string) => void;
};
function SnapshotRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedSnapshots.has(props.snapshot.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.snapshot.id)}
>
<div className="flex-1 flex items-center gap-4">
<div className="font-medium min-w-0 flex-shrink-0">
{props.snapshot.name}
</div>
<Badge variant="neutral" className="flex-shrink-0 ml-6">
{getSnapshotTypeLabel(__, props.snapshot.type)}
</Badge>
<div className="text-sm text-txt-secondary min-w-0 flex-1 text-left">
{props.snapshot.description || __("No description")}
</div>
<div className="text-sm text-txt-tertiary flex-shrink-0">
{formatDate(props.snapshot.createdAt)}
</div>
</div>
<Button
disabled={props.disabled}
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} />
{" "}
{isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -140,12 +140,6 @@ export const frameworkControlNodeQuery = graphql`
canDeleteAuditMapping: permission(
action: "core:control:delete-audit-mapping"
)
canCreateSnapshotMapping: permission(
action: "core:control:create-snapshot-mapping"
)
canDeleteSnapshotMapping: permission(
action: "core:control:delete-snapshot-mapping"
)
canCreateObligationMapping: permission(
action: "core:control:create-obligation-mapping"
)
@@ -192,16 +186,6 @@ export const frameworkControlNodeQuery = graphql`
}
}
}
snapshots(first: 100)
@connection(key: "FrameworkGraphControl_snapshots") {
__id
edges {
node {
id
...LinkedSnapshotsCardFragment
}
}
}
}
}
}

View File

@@ -50,10 +50,10 @@ export function useDeleteRiskMutation() {
}
export const risksQuery = graphql`
query RiskGraphListQuery($organizationId: ID!, $snapshotId: ID) {
query RiskGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...RiskGraphFragment @arguments(snapshotId: $snapshotId)
...RiskGraphFragment
}
}
`;
@@ -70,22 +70,28 @@ const risksFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
canCreateRisk: permission(action: "core:risk:create")
canPublishRisk: permission(action: "core:risk:publish")
risksDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
risks(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: { snapshotId: $snapshotId }
) @connection(key: "RisksListQuery_risks", filters: ["filter"]) {
) @connection(key: "RisksListQuery_risks", filters: []) {
__id
edges {
node {
id
snapshotId
name
category
treatment
@@ -130,7 +136,6 @@ export const riskNodeQuery = graphql`
node(id: $riskId) {
... on Risk {
id
snapshotId
name
description
treatment

View File

@@ -1,148 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const SnapshotsConnectionKey = "SnapshotsPage_snapshots";
export const snapshotsQuery = graphql`
query SnapshotGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
canCreateSnapshot: permission(action: "core:snapshot:create")
...SnapshotsPageFragment
}
}
}
`;
export const snapshotNodeQuery = graphql`
query SnapshotGraphNodeQuery($snapshotId: ID!) {
node(id: $snapshotId) {
... on Snapshot {
id
name
description
type
organization {
id
name
}
createdAt
}
}
}
`;
export const createSnapshotMutation = graphql`
mutation SnapshotGraphCreateMutation(
$input: CreateSnapshotInput!
$connections: [ID!]!
) {
createSnapshot(input: $input) {
snapshotEdge @prependEdge(connections: $connections) {
node {
id
name
description
type
createdAt
}
}
}
}
`;
export const deleteSnapshotMutation = graphql`
mutation SnapshotGraphDeleteMutation(
$input: DeleteSnapshotInput!
$connections: [ID!]!
) {
deleteSnapshot(input: $input) {
deletedSnapshotId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteSnapshot = (
snapshot: { id: string; name: string },
connectionId: string,
) => {
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(deleteSnapshotMutation, {
successMessage: __("Snapshot deleted successfully"),
errorMessage: __("Failed to delete snapshot"),
});
const confirm = useConfirm();
return () => {
confirm(
() =>
mutate({
variables: {
input: {
snapshotId: snapshot.id,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete the snapshot %s. This action cannot be undone.",
),
snapshot.name,
),
},
);
};
};
export const useCreateSnapshot = (connectionId: string) => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(createSnapshotMutation);
const { __ } = useTranslate();
return (input: {
organizationId: string;
name: string;
description?: string;
}) => {
if (!input.organizationId) {
return alert(__("Failed to create snapshot: organization is required"));
}
if (!input.name) {
return alert(__("Failed to create snapshot: name is required"));
}
return promisifyMutation(mutate)({
variables: {
input: {
organizationId: input.organizationId,
name: input.name,
description: input.description,
},
connections: [connectionId],
},
});
};
};

View File

@@ -19,7 +19,6 @@ import {
IconBook,
IconBox,
IconCircleProgress,
IconClock,
IconFire3,
IconGroup1,
IconInboxEmpty,
@@ -62,7 +61,6 @@ const fragment = graphql`
action: "core:processing-activity:list"
)
canListRightsRequests: permission(action: "core:rights-request:list")
canListSnapshots: permission(action: "core:snapshot:list")
canGetTrustCenter: permission(action: "core:trust-center:get")
canListCookieBanners: permission(action: "core:cookie-banner:list")
canUpdateOrganization: permission(action: "iam:organization:update")
@@ -199,13 +197,6 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/rights-requests`}
/>
)}
{organization.canListSnapshots && (
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
)}
{organization.canListAccessReviewCampaigns && (
<SidebarItem
label={__("Access Reviews")}

View File

@@ -45,7 +45,6 @@ import { LinkedAuditsCard } from "#/components/audits/LinkedAuditsCard";
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard";
import { LinkedSnapshotsCard } from "#/components/snapshots/LinkedSnapshotsCard";
import { frameworkControlNodeQuery } from "#/hooks/graph/FrameworkGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -159,33 +158,6 @@ const detachObligationMutation = graphql`
}
`;
const attachSnapshotMutation = graphql`
mutation FrameworkControlPageAttachSnapshotMutation(
$input: CreateControlSnapshotMappingInput!
$connections: [ID!]!
) {
createControlSnapshotMapping(input: $input) {
snapshotEdge @prependEdge(connections: $connections) {
node {
id
...LinkedSnapshotsCardFragment
}
}
}
}
`;
const detachSnapshotMutation = graphql`
mutation FrameworkControlPageDetachSnapshotMutation(
$input: DeleteControlSnapshotMappingInput!
$connections: [ID!]!
) {
deleteControlSnapshotMapping(input: $input) {
deletedSnapshotId @deleteEdge(connections: $connections)
}
}
`;
const deleteControlMutation = graphql`
mutation FrameworkControlPageDeleteControlMutation(
$input: DeleteControlInput!
@@ -236,14 +208,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
// eslint-disable-next-line relay/generated-typescript-types
const [attachAudit, isAttachingAudit] = useMutation(attachAuditMutation);
// eslint-disable-next-line relay/generated-typescript-types
const [detachSnapshot, isDetachingSnapshot] = useMutation(
detachSnapshotMutation,
);
// eslint-disable-next-line relay/generated-typescript-types
const [attachSnapshot, isAttachingSnapshot] = useMutation(
attachSnapshotMutation,
);
// eslint-disable-next-line relay/generated-typescript-types
const [deleteControl] = useMutation(deleteControlMutation);
// eslint-disable-next-line relay/generated-typescript-types
@@ -267,10 +231,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
const canUnlinkAudit = control.canDeleteAuditMapping;
const auditsReadOnly = !canLinkAudit && !canUnlinkAudit;
const canLinkSnapshot = control.canCreateSnapshotMapping;
const canUnlinkSnapshot = control.canDeleteSnapshotMapping;
const snapshotsReadOnly = !canLinkSnapshot && !canUnlinkSnapshot;
const canLinkObligation = control.canCreateObligationMapping;
const canUnlinkObligation = control.canDeleteObligationMapping;
const obligationsReadOnly = !canLinkObligation && !canUnlinkObligation;
@@ -482,27 +442,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
readOnly={obligationsReadOnly}
/>
</div>
<div className="mb-4">
<LinkedSnapshotsCard
variant="card"
snapshots={
control.snapshots?.edges.map(edge => edge.node)
?? []
}
params={{ controlId: control.id }}
connectionId={control.snapshots?.__id ?? ""}
onAttach={withErrorHandling(
attachSnapshot,
__("Failed to link snapshot"),
)}
onDetach={withErrorHandling(
detachSnapshot,
__("Failed to unlink snapshot"),
)}
disabled={isAttachingSnapshot || isDetachingSnapshot}
readOnly={snapshotsReadOnly}
/>
</div>
</div>
</div>
);

View File

@@ -114,10 +114,9 @@ export default function MeasureEvidencesTab() {
const { measure } = useOutletContext<{
measure: MeasureEvidencesTabFragment$key;
}>();
const { measureId, evidenceId, snapshotId } = useParams<{
const { measureId, evidenceId } = useParams<{
measureId: string;
evidenceId: string;
snapshotId?: string;
}>();
if (!measureId) {
throw new Error("Missing :measureId param in route");
@@ -132,7 +131,6 @@ export default function MeasureEvidencesTab() {
const evidence = evidences.find(e => e.id === evidenceId);
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(pagination.data.name + " - " + __("Evidences"));
@@ -156,11 +154,9 @@ export default function MeasureEvidencesTab() {
measureId={measureId}
organizationId={organizationId}
connectionId={connectionId}
hideActions={isSnapshotMode}
snapshotId={snapshotId}
/>
))}
{!isSnapshotMode && pagination.data.canUploadEvidence && (
{pagination.data.canUploadEvidence && (
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
@@ -175,16 +171,13 @@ export default function MeasureEvidencesTab() {
<EvidencePreviewDialog
key={evidence.id}
onClose={() => {
const baseUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/measures/${measureId}/evidences`
: `/organizations/${organizationId}/measures/${measureId}/evidences`;
void navigate(baseUrl);
void navigate(`/organizations/${organizationId}/measures/${measureId}/evidences`);
}}
evidenceId={evidence.id}
filename={evidence.file?.fileName || ""}
/>
)}
{!isSnapshotMode && pagination.data.canUploadEvidence && (
{pagination.data.canUploadEvidence && (
<CreateEvidenceDialog
ref={dialogRef}
measureId={measureId}
@@ -200,8 +193,6 @@ function EvidenceRow(props: {
measureId: string;
organizationId: string;
connectionId: string;
hideActions?: boolean;
snapshotId?: string;
}) {
const evidence = useFragment(evidenceFragment, props.evidenceKey);
const { __ } = useTranslate();
@@ -253,9 +244,7 @@ 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}`;
const evidenceUrl = `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`;
return (
<>
@@ -275,26 +264,24 @@ function EvidenceRow(props: {
<Td>{fileSize(__, evidence.file?.size || 0)}</Td>
<Td>{formatDate(evidence.createdAt)}</Td>
<Td noLink>
{!props.hideActions && (
<div className="flex gap-2">
<ActionDropdown>
<DropdownItem onClick={() => setIsDownloading(true)}>
<IconArrowInbox size={16} />
{__("Download")}
<div className="flex gap-2">
<ActionDropdown>
<DropdownItem onClick={() => setIsDownloading(true)}>
<IconArrowInbox size={16} />
{__("Download")}
</DropdownItem>
{evidence.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
{evidence.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</div>
)}
)}
</ActionDropdown>
</div>
</Td>
</Tr>
</>

View File

@@ -12,11 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
getTreatment,
sprintf,
validateSnapshotConsistency,
} from "@probo/helpers";
import { getTreatment, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
@@ -41,7 +37,6 @@ import { Outlet, useNavigate, useParams } from "react-router";
import { ConnectionHandler } from "relay-runtime";
import type { RiskGraphNodeQuery } from "#/__generated__/core/RiskGraphNodeQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
riskNodeQuery,
RisksConnectionKey,
@@ -56,13 +51,11 @@ type Props = {
};
export default function RiskDetailPage(props: Props) {
const { riskId, snapshotId } = useParams<{
const { riskId } = 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");
@@ -74,7 +67,6 @@ export default function RiskDetailPage(props: Props) {
props.queryRef,
);
validateSnapshotConsistency(risk, snapshotId);
const [deleteRisk] = useDeleteRiskMutation();
usePageTitle(risk.name ?? "Risk detail");
@@ -84,7 +76,6 @@ export default function RiskDetailPage(props: Props) {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
RisksConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
confirm(
() =>
@@ -95,11 +86,7 @@ export default function RiskDetailPage(props: Props) {
connections: [connectionId],
},
onSuccess() {
const risksUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
: `/organizations/${organizationId}/risks`;
void navigate(risksUrl);
void navigate(`/organizations/${organizationId}/risks`);
resolve();
},
});
@@ -120,19 +107,11 @@ export default function RiskDetailPage(props: Props) {
const controlsCount = risk.controlsInfo?.totalCount ?? 0;
const obligationsCount = risk.obligationsInfo?.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}`;
const risksUrl = `/organizations/${organizationId}/risks`;
const baseTabUrl = `/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
@@ -146,56 +125,50 @@ export default function RiskDetailPage(props: Props) {
},
]}
/>
{!isSnapshotMode && (
<div className="flex gap-2">
{risk.canUpdate && (
<FormRiskDialog
trigger={(
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
)}
risk={{ id: riskId, ...risk }}
/>
)}
{risk.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
)}
<div className="flex gap-2">
{risk.canUpdate && (
<FormRiskDialog
trigger={(
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
)}
risk={{ id: riskId, ...risk }}
/>
)}
{risk.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
</div>
<PageHeader title={risk.name} description={risk.description} />
<Tabs>
<TabLink to={`${baseTabUrl}/overview`}>{__("Overview")}</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>
<TabLink to={`${baseTabUrl}/obligations`}>
{__("Obligations")}
<TabBadge>{obligationsCount}</TabBadge>
</TabLink>
</>
)}
<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>
<TabLink to={`${baseTabUrl}/obligations`}>
{__("Obligations")}
<TabBadge>{obligationsCount}</TabBadge>
</TabLink>
</Tabs>
<Outlet context={{ risk }} />

View File

@@ -19,9 +19,11 @@ import {
ActionDropdown,
Button,
DropdownItem,
IconPageTextLine,
IconPencil,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
RisksChart,
SeverityBadge,
@@ -34,16 +36,16 @@ import {
useDialogRef,
} from "@probo/ui";
import type { PreloadedQuery } from "react-relay";
import { useParams } from "react-router";
import { useNavigate } from "react-router";
import type { RiskGraphFragment$data } from "#/__generated__/core/RiskGraphFragment.graphql";
import type { RiskGraphListQuery } from "#/__generated__/core/RiskGraphListQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useDeleteRiskMutation, useRisksQuery } from "#/hooks/graph/RiskGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog";
import FormRiskDialog from "./FormRiskDialog";
type Props = {
@@ -53,11 +55,10 @@ type Props = {
export default function RisksPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const {
data: { canCreateRisk },
data: { canCreateRisk, canPublishRisk, risksDocument },
connectionId,
risks,
...pagination
@@ -70,7 +71,6 @@ export default function RisksPage(props: Props) {
}) => {
pagination.refetch(
{
snapshotId,
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as
@@ -90,27 +90,54 @@ export default function RisksPage(props: Props) {
usePageTitle(__("Risks"));
const hasAnyAction
= !isSnapshotMode
&& risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
= risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
const defaultApproverIds
= risksDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
<div className="space-y-6">
{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 && canCreateRisk && (
<FormRiskDialog
connection={connectionId}
onSuccess={() => {
pagination.refetch({ snapshotId });
}}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
)}
<div className="flex gap-2">
{risksDocument && (
<Button
variant="secondary"
icon={IconPageTextLine}
onClick={() => void navigate(
`/organizations/${organizationId}/documents/${risksDocument.id}`,
)}
>
{__("Document")}
</Button>
)}
{canPublishRisk && (
<PublishRiskListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={documentId => void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
)}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishRiskListDialog>
)}
{canCreateRisk && (
<FormRiskDialog
connection={connectionId}
onSuccess={() => {
pagination.refetch({});
}}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
)}
</div>
</PageHeader>
<div className="grid grid-cols-2 gap-4">
@@ -167,8 +194,6 @@ 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 = () => {
@@ -195,20 +220,15 @@ 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`;
const riskUrl = `/organizations/${organizationId}/risks/${risk.id}/overview`;
return (
<>
{!isSnapshotMode && (
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
)}
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
<Tr to={riskUrl}>
<Td>{risk.name}</Td>
<Td>{risk.category}</Td>

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishRiskListDialogMutation } from "#/__generated__/core/PublishRiskListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishRiskListDialogMutation(
$input: PublishRiskListInput!
) {
publishRiskList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishRiskListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() =>
z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishRiskListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishRiskList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Risks published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish risks"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Risks")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { getSnapshotTypeUrlPath } from "@probo/helpers";
import { useEffect } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useNavigate, useParams } from "react-router";
import type { SnapshotGraphNodeQuery } from "#/__generated__/core/SnapshotGraphNodeQuery.graphql";
import { PageError } from "#/components/PageError";
import { snapshotNodeQuery } from "#/hooks/graph/SnapshotGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = {
queryRef: PreloadedQuery<SnapshotGraphNodeQuery>;
};
export default function SnapshotDetailPage({ queryRef }: Props) {
const navigate = useNavigate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams();
const data = usePreloadedQuery(snapshotNodeQuery, queryRef);
useEffect(() => {
if (!data.node || !data.node.type) {
return;
}
const snapshot = data.node;
const snapshotType = snapshot.type;
const urlPath = getSnapshotTypeUrlPath(snapshotType);
void navigate(
`/organizations/${organizationId}/snapshots/${snapshotId}${urlPath}`,
{
replace: true,
},
);
}, [data.node, navigate, organizationId, snapshotId]);
if (!data.node || !data.node.type) {
return <PageError />;
}
return null;
}

View File

@@ -1,196 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
formatDate,
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
} from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Button,
DropdownItem,
IconPlusLarge,
IconTrashCan,
PageHeader,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@probo/ui";
import {
type PreloadedQuery,
useFragment,
usePreloadedQuery,
} from "react-relay";
import { graphql } from "relay-runtime";
import type { SnapshotGraphListQuery } from "#/__generated__/core/SnapshotGraphListQuery.graphql";
import type {
SnapshotsPageFragment$data,
SnapshotsPageFragment$key,
} from "#/__generated__/core/SnapshotsPageFragment.graphql";
import { snapshotsQuery, useDeleteSnapshot } from "#/hooks/graph/SnapshotGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import SnapshotFormDialog from "./dialog/SnapshotFormDialog";
type Props = {
queryRef: PreloadedQuery<SnapshotGraphListQuery>;
};
const snapshotsFragment = graphql`
fragment SnapshotsPageFragment on Organization {
snapshots(first: 100)
@connection(key: "SnapshotsGraphListQuery__snapshots") {
__id
edges {
node {
id
name
description
type
createdAt
canDelete: permission(action: "core:snapshot:delete")
}
}
}
}
`;
export default function SnapshotsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const organization = usePreloadedQuery(
snapshotsQuery,
props.queryRef,
).organization;
const data = useFragment<SnapshotsPageFragment$key>(
snapshotsFragment,
organization,
);
const connectionId = data.snapshots.__id;
const snapshots = data.snapshots.edges.map(edge => edge.node);
usePageTitle(__("Snapshots"));
const hasAnyAction = snapshots.some(({ canDelete }) => canDelete);
return (
<div className="space-y-6">
<PageHeader
title={__("Snapshots")}
description={__(
"Snapshots capture point-in-time views of your organization's compliance state. Create snapshots to track progress over time.",
)}
>
{organization.canCreateSnapshot && (
<SnapshotFormDialog connection={connectionId}>
<Button variant="primary" icon={IconPlusLarge}>
{__("New snapshot")}
</Button>
</SnapshotFormDialog>
)}
</PageHeader>
{snapshots.length > 0
? (
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("Description")}</Th>
<Th>{__("Created")}</Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{snapshots.map(snapshot => (
<SnapshotRow
key={snapshot.id}
snapshot={snapshot}
connectionId={connectionId}
organizationId={organizationId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
</Table>
)
: (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-txt-secondary mb-2">
{__("No snapshots yet")}
</h3>
<p className="text-txt-tertiary mb-6">
{__("Create your first snapshot to get started")}
</p>
</div>
)}
</div>
);
}
type SnapshotRowProps = {
snapshot: NodeOf<SnapshotsPageFragment$data["snapshots"]>;
connectionId: string;
organizationId: string;
hasAnyAction: boolean;
};
function SnapshotRow(props: SnapshotRowProps) {
const { __ } = useTranslate();
const deleteSnapshot = useDeleteSnapshot(props.snapshot, props.connectionId);
const typePath = getSnapshotTypeUrlPath(props.snapshot.type);
return (
<Tr
to={`/organizations/${props.organizationId}/snapshots/${props.snapshot.id}${typePath}`}
>
<Td className="font-medium">{props.snapshot.name}</Td>
<Td>
<Badge variant="neutral">
{getSnapshotTypeLabel(__, props.snapshot.type)}
</Badge>
</Td>
<Td className="text-txt-secondary">
{props.snapshot.description || __("No description")}
</Td>
<Td className="text-txt-tertiary">
{formatDate(props.snapshot.createdAt)}
</Td>
{props.hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{props.snapshot.canDelete && (
<DropdownItem
onClick={deleteSnapshot}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,153 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { snapshotTypes } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Input,
Label,
PropertyRow,
Textarea,
useDialogRef,
} from "@probo/ui";
import type { ReactNode } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import { ControlledField } from "#/components/form/ControlledField";
import { SnapshotTypeOptions } from "#/components/form/SnapshotTypeOptions";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const snapshotCreateMutation = graphql`
mutation SnapshotFormDialogCreateMutation(
$input: CreateSnapshotInput!
$connections: [ID!]!
) {
createSnapshot(input: $input) {
snapshotEdge @prependEdge(connections: $connections) {
node {
id
name
description
type
createdAt
canDelete: permission(action: "core:snapshot:delete")
}
}
}
}
`;
const snapshotSchema = z.object({
name: z.string().min(2, { message: "Name is required" }),
description: z.string().optional(),
type: z.enum(snapshotTypes),
});
type Props = {
children?: ReactNode;
connection?: string;
};
export default function SnapshotFormDialog(props: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const organizationId = useOrganizationId();
const [mutate] = useMutationWithToasts(snapshotCreateMutation, {
successMessage: __("Snapshot created successfully."),
errorMessage: __("Failed to create snapshot"),
});
const {
handleSubmit,
register,
reset,
control,
formState: { errors },
} = useFormWithSchema(snapshotSchema, {
defaultValues: {
name: "",
description: "",
type: "RISKS",
},
});
const onSubmit = async (data: z.infer<typeof snapshotSchema>) => {
await mutate({
variables: {
input: {
organizationId,
name: data.name,
description: data.description || undefined,
type: data.type,
},
connections: props.connection ? [props.connection] : [],
},
});
reset();
dialogRef.current?.close();
};
return (
<Dialog
ref={dialogRef}
trigger={props.children}
title={<Breadcrumb items={[__("Snapshots"), __("New Snapshot")]} />}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent className="grid grid-cols-[1fr_420px]">
<div className="py-8 px-10 space-y-4">
<Input
id="name"
required
variant="title"
placeholder={__("Snapshot name")}
{...register("name")}
/>
<Textarea
id="description"
variant="ghost"
autogrow
placeholder={__("Add description (optional)")}
{...register("description")}
/>
</div>
{/* Properties form */}
<div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label>
<PropertyRow
id="type"
label={__("Type")}
error={errors.type?.message}
>
<ControlledField control={control} name="type" type="select">
<SnapshotTypeOptions />
</ControlledField>
</PropertyRow>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit">{__("Create snapshot")}</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -30,7 +30,7 @@ import {
import { clsx } from "clsx";
import { useState } from "react";
import { Controller } from "react-hook-form";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { useVendorForm } from "#/hooks/forms/useVendorForm";
@@ -44,13 +44,11 @@ export default function VendorCertificationsTab() {
}>();
const { __ } = useTranslate();
const { control, handleSubmit } = useVendorForm(vendor);
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
return (
<form
className="space-y-4"
onSubmit={!isSnapshotMode && vendor.canUpdate
onSubmit={vendor.canUpdate
? e => void handleSubmit(e)
: undefined}
>
@@ -62,12 +60,12 @@ export default function VendorCertificationsTab() {
<Certifications
onValueChange={field.onChange}
value={field.value ?? []}
readOnly={isSnapshotMode || !vendor.canUpdate}
readOnly={!vendor.canUpdate}
/>
)}
/>
</Card>
{!isSnapshotMode && vendor.canUpdate && (
{vendor.canUpdate && (
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
</div>

View File

@@ -32,7 +32,7 @@ import {
} from "@probo/ui";
import type { ComponentProps } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
@@ -110,8 +110,6 @@ export default function VendorComplianceTab() {
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(vendor.name + " - " + __("Compliance reports"));
return (
@@ -120,7 +118,7 @@ export default function VendorComplianceTab() {
title={__("Compliance reports")}
description={__("Track vendor compliance certifications and reports.")}
>
{!isSnapshotMode && vendor.canUploadComplianceReport && (
{vendor.canUploadComplianceReport && (
<UploadComplianceReportDialog
vendorId={vendor.id}
connectionId={connectionId}
@@ -139,7 +137,7 @@ export default function VendorComplianceTab() {
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
<Th>{__("Valid until")}</Th>
<Th>{__("File size")}</Th>
{!isSnapshotMode && reports.length > 0 && <Th>{__("Actions")}</Th>}
{reports.length > 0 && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -148,7 +146,6 @@ export default function VendorComplianceTab() {
key={report.id}
reportKey={report}
connectionId={connectionId}
isSnapshotMode={isSnapshotMode}
/>
))}
</Tbody>
@@ -160,7 +157,6 @@ export default function VendorComplianceTab() {
type ReportRowProps = {
reportKey: VendorComplianceTabFragment_report$key;
connectionId: string;
isSnapshotMode: boolean;
};
function ReportRow(props: ReportRowProps) {
@@ -203,33 +199,31 @@ function ReportRow(props: ReportRowProps) {
<Td>{formatDate(report.reportDate)}</Td>
<Td>{formatDate(report.validUntil)}</Td>
<Td>{fileSize(__, report.file?.size ?? 0)}</Td>
{!props.isSnapshotMode && (
<Td width={50} className="text-end">
<ActionDropdown>
{report.file?.downloadUrl && (
<DropdownItem
icon={IconArrowDown}
onClick={() =>
downloadFile(
report.file!.downloadUrl,
report.file!.fileName,
)}
>
{__("Download")}
</DropdownItem>
)}
{report.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
<Td width={50} className="text-end">
<ActionDropdown>
{report.file?.downloadUrl && (
<DropdownItem
icon={IconArrowDown}
onClick={() =>
downloadFile(
report.file!.downloadUrl,
report.file!.fileName,
)}
>
{__("Download")}
</DropdownItem>
)}
{report.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
</Tr>
);
}

View File

@@ -32,7 +32,7 @@ import {
} from "@probo/ui";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorContactsListQuery } from "#/__generated__/core/VendorContactsListQuery.graphql";
@@ -112,8 +112,6 @@ export default function VendorContactsTab() {
const connectionId = data.contacts.__id;
const contacts = data.contacts.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [editingContact, setEditingContact]
= useState<VendorContactsTabFragment_contact$data | null>(null);
const hasAnyAction = contacts.some(
@@ -128,7 +126,7 @@ export default function VendorContactsTab() {
title={__("Contacts")}
description={__("Manage vendor contacts and their information.")}
>
{!isSnapshotMode && vendor.canCreateContact && (
{vendor.canCreateContact && (
<CreateContactDialog vendorId={vendor.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
@@ -144,7 +142,7 @@ export default function VendorContactsTab() {
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
<Th>{__("Phone")}</Th>
<Th>{__("Role")}</Th>
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -154,13 +152,12 @@ export default function VendorContactsTab() {
contactKey={contact}
connectionId={connectionId}
onEdit={setEditingContact}
isSnapshotMode={isSnapshotMode}
/>
))}
</Tbody>
</SortableTable>
{editingContact && !isSnapshotMode && editingContact.canUpdate && (
{editingContact && editingContact.canUpdate && (
<EditContactDialog
contactId={editingContact.id}
contact={editingContact}
@@ -175,7 +172,6 @@ type ContactRowProps = {
contactKey: VendorContactsTabFragment_contact$key;
connectionId: string;
onEdit: (contact: VendorContactsTabFragment_contact$data) => void;
isSnapshotMode: boolean;
};
function ContactRow(props: ContactRowProps) {
@@ -245,7 +241,7 @@ function ContactRow(props: ContactRowProps) {
)}
</Td>
<Td>{contact.role || __("—")}</Td>
{!props.isSnapshotMode && hasAnyAction && (
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{contact.canUpdate && (

View File

@@ -28,7 +28,7 @@ import {
import type { VendorCategory } from "@probo/vendors";
import { useMemo } from "react";
import { graphql, useFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
@@ -112,8 +112,6 @@ export default function VendorOverviewTab() {
{ value: "VERSION_CONTROL", label: __("Version Control") },
];
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const {
control,
@@ -158,11 +156,11 @@ export default function VendorOverviewTab() {
usePageTitle(vendor.name + " - " + __("Overview"));
const isFormDisabled = isSubmitting || isSnapshotMode || !vendor.canUpdate;
const isFormDisabled = isSubmitting || !vendor.canUpdate;
return (
<form
onSubmit={isSnapshotMode || !vendor.canUpdate
onSubmit={!vendor.canUpdate
? undefined
: e => void handleSubmit(e)}
className="space-y-12"
@@ -330,7 +328,7 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && businessAssociateAgreement.canUpdate && (
{businessAssociateAgreement.canUpdate && (
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
agreement={{
@@ -342,7 +340,7 @@ export default function VendorOverviewTab() {
<Button variant="quaternary" icon={IconPencil} />
</EditBusinessAssociateAgreementDialog>
)}
{!isSnapshotMode && businessAssociateAgreement.canDelete && (
{businessAssociateAgreement.canDelete && (
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
fileName={businessAssociateAgreement.fileName}
@@ -354,8 +352,7 @@ export default function VendorOverviewTab() {
</>
)
: (
!isSnapshotMode
&& vendor.canUploadBAA && (
vendor.canUploadBAA && (
<UploadBusinessAssociateAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -405,7 +402,7 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && dataPrivacyAgreement.canUpdate && (
{dataPrivacyAgreement.canUpdate && (
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
agreement={{
@@ -417,7 +414,7 @@ export default function VendorOverviewTab() {
<Button variant="quaternary" icon={IconPencil} />
</EditDataPrivacyAgreementDialog>
)}
{!isSnapshotMode && dataPrivacyAgreement.canDelete && (
{dataPrivacyAgreement.canDelete && (
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
fileName={dataPrivacyAgreement.fileName}
@@ -429,8 +426,7 @@ export default function VendorOverviewTab() {
</>
)
: (
!isSnapshotMode
&& vendor.canUploadDPA && (
vendor.canUploadDPA && (
<UploadDataPrivacyAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -447,15 +443,13 @@ export default function VendorOverviewTab() {
</div>
{/* Submit */}
{!isSnapshotMode && (
<div className="flex justify-end">
{vendor.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>
)}
</div>
)}
<div className="flex justify-end">
{vendor.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>
)}
</div>
</form>
);
}

View File

@@ -30,7 +30,7 @@ import {
import { clsx } from "clsx";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
@@ -96,8 +96,6 @@ export default function VendorRiskAssessmentTab() {
>(riskAssessmentsFragment, vendor);
const assessments = data.riskAssessments.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [expanded, setExpanded] = useState<string | null>(null);
usePageTitle(vendor.name + " - " + __("Risk Assessments"));
@@ -106,7 +104,7 @@ export default function VendorRiskAssessmentTab() {
return (
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
{__("No risk assessments found")}
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
{vendor.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}
@@ -136,7 +134,7 @@ export default function VendorRiskAssessmentTab() {
</Tr>
</Thead>
<Tbody>
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
{vendor.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}

View File

@@ -32,7 +32,7 @@ import {
} from "@probo/ui";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
@@ -110,8 +110,6 @@ export default function VendorServicesTab() {
const connectionId = data.services.__id;
const services = data.services.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [editingService, setEditingService]
= useState<VendorServicesTabFragment_service$data | null>(null);
const hasAnyAction = services.some(
@@ -126,7 +124,7 @@ export default function VendorServicesTab() {
title={__("Services")}
description={__("Manage services provided by this vendor.")}
>
{!isSnapshotMode && vendor.canCreateService && (
{vendor.canCreateService && (
<CreateServiceDialog vendorId={vendor.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
@@ -140,7 +138,7 @@ export default function VendorServicesTab() {
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Description")}</Th>
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -150,13 +148,12 @@ export default function VendorServicesTab() {
serviceKey={service}
connectionId={connectionId}
onEdit={setEditingService}
isSnapshotMode={isSnapshotMode}
/>
))}
</Tbody>
</SortableTable>
{editingService && !isSnapshotMode && editingService.canUpdate && (
{editingService && editingService.canUpdate && (
<EditServiceDialog
serviceId={editingService.id}
service={editingService}
@@ -171,7 +168,6 @@ type ServiceRowProps = {
serviceKey: VendorServicesTabFragment_service$key;
connectionId: string;
onEdit: (service: VendorServicesTabFragment_service$data) => void;
isSnapshotMode: boolean;
};
function ServiceRow(props: ServiceRowProps) {
@@ -213,7 +209,7 @@ function ServiceRow(props: ServiceRowProps) {
<Tr>
<Td>{service.name}</Td>
<Td>{service.description || __("—")}</Td>
{!props.isSnapshotMode && hasAnyAction && (
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{service.canUpdate && (

View File

@@ -45,7 +45,6 @@ import { obligationRoutes } from "./routes/obligationRoutes";
import { processingActivityRoutes } from "./routes/processingActivityRoutes";
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
import { riskRoutes } from "./routes/riskRoutes";
import { snapshotsRoutes } from "./routes/snapshotsRoutes";
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
import { taskRoutes } from "./routes/taskRoutes";
import { vendorRoutes } from "./routes/vendorRoutes";
@@ -307,7 +306,6 @@ const routes = [
...accessReviewRoutes,
...compliancePageRoutes,
...cookieBannerRoutes,
...snapshotsRoutes,
{
path: "*",
Component: PageError,

View File

@@ -37,20 +37,6 @@ export const riskRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<RiskGraphListQuery>(coreEnvironment, risksQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/risks/RisksPage")),
),
},
{
path: "snapshots/:snapshotId/risks",
Fallback: RisksPageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<RiskGraphListQuery>(coreEnvironment, risksQuery, {
organizationId: organizationId,
snapshotId: snapshotId,
}),
),
Component: withQueryRef(
@@ -115,33 +101,4 @@ export const riskRoutes = [
},
],
},
{
path: "snapshots/:snapshotId/risks/:riskId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ riskId }) =>
loadQuery<RiskGraphNodeQuery>(coreEnvironment, riskNodeQuery, {
riskId: riskId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/risks/RiskDetailPage")),
),
children: [
{
path: "",
loader: () => {
// eslint-disable-next-line
throw redirect("overview");
},
Component: Fragment,
},
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("#/pages/organizations/risks/tabs/RiskOverviewTab"),
),
},
],
},
] satisfies AppRoute[];

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { lazy } from "@probo/react-lazy";
import {
type AppRoute,
loaderFromQueryLoader,
withQueryRef,
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { SnapshotGraphListQuery } from "#/__generated__/core/SnapshotGraphListQuery.graphql";
import type { SnapshotGraphNodeQuery } from "#/__generated__/core/SnapshotGraphNodeQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { snapshotNodeQuery, snapshotsQuery } from "#/hooks/graph/SnapshotGraph";
export const snapshotsRoutes = [
{
path: "snapshots",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<SnapshotGraphListQuery>(coreEnvironment, snapshotsQuery, {
organizationId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/snapshots/SnapshotsPage")),
),
},
{
path: "snapshots/:snapshotId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ snapshotId }) =>
loadQuery<SnapshotGraphNodeQuery>(coreEnvironment, snapshotNodeQuery, {
snapshotId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/snapshots/SnapshotDetailPage")),
),
},
] satisfies AppRoute[];