Add third-party self-referential relations

Introduce a self-referential many-to-many relation table so a
third party can have child third parties. Each relation is
directional (parent to child); both directions can coexist as
independent rows.

Add a first_level boolean on third_parties (default true) with
a filter on the list page that defaults to showing only
first-level third parties.

Frontend adds a "Third Parties" tab on the detail page where
users can link existing third parties or create new ones from
the common third party catalog (created as non-first-level).
The list page gets a First Level/All toggle filter.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-19 19:13:16 +02:00
parent 8ff68798fb
commit b6b1e801b1
37 changed files with 2399 additions and 45 deletions

View File

@@ -24,6 +24,7 @@ type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
firstLevel?: boolean;
};
type Props<T extends FieldValues = FieldValues> = {
@@ -66,7 +67,7 @@ function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
const thirdParties = useThirdParties(organizationId);
const [isOpen, setIsOpen] = useState(false);
const allThirdParties = [...thirdParties];
const allThirdParties: ThirdParty[] = [...thirdParties];
if (props.disabled) {
selectedThirdParties.forEach((selectedThirdParty) => {
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {

View File

@@ -138,6 +138,7 @@ export const paginatedThirdPartiesFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
) {
thirdParties(
first: $first
@@ -145,7 +146,8 @@ export const paginatedThirdPartiesFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartiesListQuery_thirdParties") {
filter: $filter
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
__id
edges {
node {
@@ -182,6 +184,7 @@ export const thirdPartyNodeQuery = graphql`
... on ThirdParty {
name
websiteUrl
firstLevel
canAssess: permission(action: "core:thirdParty:assess")
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
@@ -224,6 +227,7 @@ export const thirdPartiesSelectQuery = graphql`
id
name
websiteUrl
firstLevel
}
}
}

View File

@@ -26,12 +26,15 @@ import {
IconUpload,
PageHeader,
RiskBadge,
TabItem,
Tabs,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@probo/ui";
import { useState, useTransition } from "react";
import {
type PreloadedQuery,
usePaginationFragment,
@@ -76,9 +79,21 @@ export default function ThirdPartiesPage(props: Props) {
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id;
const [, startTransition] = useTransition();
const [firstLevelFilter, setFirstLevelFilter] = useState<boolean | null>(true);
usePageTitle(__("Third parties"));
const handleFilterChange = (firstLevel: boolean | null) => {
setFirstLevelFilter(firstLevel);
startTransition(() => {
pagination.refetch(
{ filter: firstLevel !== null ? { firstLevel } : {} },
{ fetchPolicy: "store-and-network" },
);
});
};
const hasAnyAction
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
@@ -129,6 +144,20 @@ export default function ThirdPartiesPage(props: Props) {
)}
</div>
</PageHeader>
<Tabs>
<TabItem
active={firstLevelFilter === true}
onClick={() => handleFilterChange(true)}
>
{__("First Level")}
</TabItem>
<TabItem
active={firstLevelFilter === null}
onClick={() => handleFilterChange(null)}
>
{__("All")}
</TabItem>
</Tabs>
<SortableTable {...pagination}>
<Thead>
<Tr>

View File

@@ -16,6 +16,7 @@ import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Breadcrumb,
Button,
DropdownItem,
@@ -91,7 +92,12 @@ export default function ThirdPartyDetailPage(props: Props) {
className="shadow-mid rounded-2xl"
/>
)}
<div className="text-2xl">{thirdParty.name}</div>
<div className="flex items-center gap-3">
<div className="text-2xl">{thirdParty.name}</div>
<Badge variant={thirdParty.firstLevel ? "info" : "neutral"}>
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
</Badge>
</div>
</div>
<div className="flex gap-2 items-center">
{thirdParty.canAssess && (
@@ -127,6 +133,9 @@ export default function ThirdPartyDetailPage(props: Props) {
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
{__("Third Parties")}
</TabLink>
</Tabs>
<Outlet context={{ thirdParty }} />

View File

@@ -0,0 +1,187 @@
// 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 { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Combobox,
ComboboxItem,
Dialog,
DialogContent,
DialogFooter,
useDialogRef,
} from "@probo/ui";
import { type ReactNode, Suspense, useCallback, useState } from "react";
import { useMutation, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { AddChildThirdPartyDialogCreateMappingMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMappingMutation.graphql";
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
const createMappingMutation = graphql`
mutation AddChildThirdPartyDialogCreateMappingMutation(
$input: CreateThirdPartyThirdPartyMappingInput!
$connections: [ID!]!
) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
name
websiteUrl
category
}
}
}
}
`;
const createThirdPartyMutation = graphql`
mutation AddChildThirdPartyDialogCreateMutation(
$input: CreateThirdPartyInput!
) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
parentThirdPartyId: string;
organizationId: string;
connectionId: string;
existingChildIds: string[];
};
export function AddChildThirdPartyDialog({
children,
parentThirdPartyId,
organizationId,
connectionId,
existingChildIds,
}: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const thirdParties = useThirdParties(organizationId);
const [createMapping] = useMutation<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const debouncedLoadQuery = useDebounceCallback(
useCallback(
(name: string) => {
loadQuery({ name });
},
[loadQuery],
),
500,
);
const handleSearch = (name: string) => {
setSearchQuery(name);
const trimmed = name.trim();
if (trimmed.length >= 2) {
debouncedLoadQuery(trimmed);
}
};
const existingThirdParties = thirdParties.filter(
tp =>
tp.id !== parentThirdPartyId
&& !existingChildIds.includes(tp.id)
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const handleSelectExisting = (childId: string) => {
createMapping({
variables: {
input: {
parentThirdPartyId,
childThirdPartyId: childId,
},
connections: [connectionId],
},
onCompleted: () => {
dialogRef.current?.close();
},
});
};
const handleSelectCommon = (common: Omit<CreateThirdPartyInput, "organizationId">) => {
createThirdParty({
variables: {
input: {
...common,
organizationId,
firstLevel: false,
},
},
onCompleted: (response) => {
const newId = response.createThirdParty.thirdPartyEdge.node.id;
createMapping({
variables: {
input: {
parentThirdPartyId,
childThirdPartyId: newId,
},
connections: [connectionId],
},
onCompleted: () => {
dialogRef.current?.close();
},
});
},
});
};
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
return (
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{existingThirdParties.map(tp => (
<ComboboxItem key={tp.id} onClick={() => handleSelectExisting(tp.id)}>
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
{tp.name}
</ComboboxItem>
))}
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
queryRef={queryRef}
excludeNames={existingNames}
onSelect={handleSelectCommon}
/>
</Suspense>
)}
</Combobox>
</DialogContent>
<DialogFooter />
</Dialog>
);
}

View File

@@ -27,7 +27,7 @@ import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraph
export type CommonThirdPartyRef
= CommonThirdPartyCombobox_commonThirdParty$data;
const commonThirdPartyFragment = graphql`
export const commonThirdPartyFragment = graphql`
fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline {
name
logoUrl
@@ -57,20 +57,44 @@ export const commonThirdPartiesQuery = graphql`
}
`;
function toCreateInput(tp: CommonThirdPartyRef): Omit<CreateThirdPartyInput, "organizationId"> {
return {
name: tp.name,
headquarterAddress: tp.headquarterAddress,
legalName: tp.legalName,
websiteUrl: tp.websiteUrl,
category: tp.category,
privacyPolicyUrl: tp.privacyPolicyUrl,
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
certifications: tp.certifications,
securityPageUrl: tp.securityPageUrl,
trustPageUrl: tp.trustPageUrl,
statusPageUrl: tp.statusPageUrl,
termsOfServiceUrl: tp.termsOfServiceUrl,
};
}
interface CommonThirdPartyComboboxProps {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
onSelect: (thirdParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
excludeNames?: Set<string>;
}
export function CommonThirdPartyCombobox({
queryRef,
onSelect,
excludeNames,
}: CommonThirdPartyComboboxProps) {
const data = usePreloadedQuery(commonThirdPartiesQuery, queryRef);
const items = excludeNames
? data.commonThirdParties.filter(tp => !excludeNames.has(tp.name.toLowerCase()))
: data.commonThirdParties;
return (
<>
{data.commonThirdParties.map(thirdParty => (
{items.map(thirdParty => (
<ComboboxItem
key={thirdParty.id}
onClick={() => {
@@ -78,21 +102,7 @@ export function CommonThirdPartyCombobox({
commonThirdPartyFragment,
thirdParty,
);
onSelect({
name: tp.name,
headquarterAddress: tp.headquarterAddress,
legalName: tp.legalName,
websiteUrl: tp.websiteUrl,
category: tp.category,
privacyPolicyUrl: tp.privacyPolicyUrl,
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
certifications: tp.certifications,
securityPageUrl: tp.securityPageUrl,
trustPageUrl: tp.trustPageUrl,
statusPageUrl: tp.statusPageUrl,
termsOfServiceUrl: tp.termsOfServiceUrl,
});
onSelect(toCreateInput(tp));
}}
>
<Avatar

View File

@@ -12,8 +12,10 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Combobox,
ComboboxItem,
Dialog,
@@ -23,17 +25,30 @@ import {
useDialogRef,
} from "@probo/ui";
import { type ReactNode, Suspense, useCallback, useState } from "react";
import { useQueryLoader } from "react-relay";
import { useMutation, useQueryLoader } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyDialogPromoteMutation } from "#/__generated__/core/CreateThirdPartyDialogPromoteMutation.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
import { useCreateThirdPartyMutation, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import {
commonThirdPartiesQuery,
CommonThirdPartyCombobox,
} from "./CommonThirdPartyCombobox";
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
const promoteMutation = graphql`
mutation CreateThirdPartyDialogPromoteMutation(
$input: UpdateThirdPartyInput!
) {
updateThirdParty(input: $input) {
thirdParty {
id
name
firstLevel
}
}
}
`;
type Props = {
children: ReactNode;
@@ -48,12 +63,48 @@ export function CreateThirdPartyDialog({
}: Props) {
const { __ } = useTranslate();
const [createThirdParty] = useCreateThirdPartyMutation();
const [promoteThirdParty] = useMutation<CreateThirdPartyDialogPromoteMutation>(promoteMutation);
const thirdParties = useThirdParties(organizationId);
const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery]
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const nonFirstLevelByName = new Map(
thirdParties
.filter(tp => !tp.firstLevel)
.map(tp => [tp.name.toLowerCase(), tp]),
);
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
const name = typeof thirdParty === "string" ? thirdParty : thirdParty.name;
const existing = nonFirstLevelByName.get(name.toLowerCase());
if (existing) {
promoteThirdParty({
variables: {
input: { id: existing.id, firstLevel: true },
},
updater: (store) => {
const payload = store.getRootField("updateThirdParty");
const node = payload?.getLinkedRecord("thirdParty");
if (!node) return;
const connectionRecord = store.get(connection);
if (!connectionRecord) return;
const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge");
ConnectionHandler.insertEdgeBefore(connectionRecord, edge);
},
onCompleted: () => {
dialogRef.current?.close();
},
});
return;
}
const input
= typeof thirdParty === "string"
? {
@@ -98,10 +149,26 @@ export function CreateThirdPartyDialog({
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{searchQuery.trim().length >= 2 && (
<>
{thirdParties
.filter(tp =>
!tp.firstLevel
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
.map(tp => (
<ComboboxItem key={tp.id} onClick={() => void onSelect(tp.name)}>
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
{tp.name}
</ComboboxItem>
))}
</>
)}
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
queryRef={queryRef}
excludeNames={existingNames}
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
/>
</Suspense>

View File

@@ -0,0 +1,237 @@
// 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 { faviconUrl, formatDate } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Button,
IconPlusLarge,
IconTrashCan,
PageHeader,
RiskBadge,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import type { ComponentProps } from "react";
import {
type PreloadedQuery,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { graphql } from "relay-runtime";
import type { ThirdPartyThirdPartiesPageDeleteMappingMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMappingMutation.graphql";
import type { ThirdPartyThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartyThirdPartiesPageFragment.graphql";
import type { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql";
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
export const thirdPartyThirdPartiesPageQuery = graphql`
query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
id
name
canUpdate: permission(action: "core:thirdParty:update")
...ThirdPartyThirdPartiesPageFragment
}
}
}
`;
const paginatedFragment = graphql`
fragment ThirdPartyThirdPartiesPageFragment on ThirdParty
@refetchable(queryName: "ThirdPartyThirdPartiesPagePaginationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
childThirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyThirdPartiesPageFragment_childThirdParties", filters: []) {
__id
edges {
node {
id
name
websiteUrl
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
id
createdAt
dataSensitivity
businessImpact
}
}
}
}
}
}
}
`;
const deleteMappingMutation = graphql`
mutation ThirdPartyThirdPartiesPageDeleteMappingMutation(
$input: DeleteThirdPartyThirdPartyMappingInput!
$connections: [ID!]!
) {
deleteThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
interface Props {
queryRef: PreloadedQuery<ThirdPartyThirdPartiesPageQuery>;
}
export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
const { node } = usePreloadedQuery(thirdPartyThirdPartiesPageQuery, queryRef);
const thirdParty = node.__typename === "ThirdParty" ? node : null;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const confirm = useConfirm();
const pagination = usePaginationFragment<
ThirdPartyThirdPartiesPagePaginationQuery,
ThirdPartyThirdPartiesPageFragment$key
>(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key);
const [deleteMapping] = useMutation<ThirdPartyThirdPartiesPageDeleteMappingMutation>(deleteMappingMutation);
usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties"));
if (!thirdParty) {
return null;
}
const connectionId = pagination.data.childThirdParties.__id;
const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node);
const handleRemove = (childId: string, childName: string) => {
confirm(
() =>
new Promise<void>((resolve, reject) => {
deleteMapping({
variables: {
input: {
parentThirdPartyId: thirdParty.id,
childThirdPartyId: childId,
},
connections: [connectionId],
},
onCompleted: () => resolve(),
onError: err => reject(err),
});
}),
{
message: `${__("Remove")} "${childName}" ${__("from this third party?")}`,
},
);
};
return (
<div className="space-y-6">
<PageHeader
title={__("Third Parties")}
description={__("Manage third parties linked to this third party.")}
>
{thirdParty.canUpdate && (
<AddChildThirdPartyDialog
parentThirdPartyId={thirdParty.id}
organizationId={organizationId}
connectionId={connectionId}
existingChildIds={childThirdParties.map(c => c.id)}
>
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
</AddChildThirdPartyDialog>
)}
</PageHeader>
<SortableTable
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Third party")}</SortableTh>
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{childThirdParties.map((child) => {
const latestAssessment = child.riskAssessments?.edges[0]?.node;
return (
<Tr
key={child.id}
to={`/organizations/${organizationId}/third-parties/${child.id}/overview`}
>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={child.name} src={faviconUrl(child.websiteUrl)} />
<div>{child.name}</div>
</div>
</Td>
<Td>
{latestAssessment?.createdAt
? formatDate(latestAssessment.createdAt)
: __("Not assessed")}
</Td>
<Td>
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
</Td>
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td noLink width={50} className="text-end">
{thirdParty.canUpdate && (
<Button
variant="tertiary"
icon={IconTrashCan}
onClick={() => handleRemove(child.id, child.name)}
/>
)}
</Td>
</Tr>
);
})}
</Tbody>
</SortableTable>
</div>
);
}

View File

@@ -0,0 +1,43 @@
// 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 { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import ThirdPartyThirdPartiesPage, { thirdPartyThirdPartiesPageQuery } from "./ThirdPartyThirdPartiesPage";
export default function ThirdPartyThirdPartiesPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery] = useQueryLoader<ThirdPartyThirdPartiesPageQuery>(thirdPartyThirdPartiesPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<ThirdPartyThirdPartiesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -97,6 +97,14 @@ export const thirdPartyRoutes = [
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
),
},
{
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
),
},
],
},
] satisfies AppRoute[];