Refactor third parties frontend to page arborescence

Mirror the risks refactor (c78a713): colocate routes.ts, split the
detail layout query so each child route owns its Loader + Page, rename
tabs/*Tab to resource folders with *Page, move dialogs into
_components/, and extract ThirdPartyRow with its own fragment.

Remove outlet context data passing and deprecated
loaderFromQueryLoader. Delete the monolithic ThirdPartyGraph hook,
colocating each GraphQL operation with its consumer: the create
mutation in CreateThirdPartyDialog (now useMutation + useToast) and
the third-party list queries in ThirdPartiesCell and
ThirdPartiesMultiSelectField (now useQueryLoader + usePreloadedQuery).

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-06-16 15:38:09 +00:00
parent 1df4af4556
commit a651d56c8c
51 changed files with 2851 additions and 1783 deletions

View File

@@ -15,16 +15,38 @@
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
import { type ComponentProps, Suspense, useState } from "react";
import { type ComponentProps, Suspense, useEffect, useState } from "react";
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
import { type PreloadedQuery, usePreloadedQuery, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import type { ThirdPartiesMultiSelectFieldQuery } from "#/__generated__/core/ThirdPartiesMultiSelectFieldQuery.graphql";
const thirdPartiesQuery = graphql`
query ThirdPartiesMultiSelectFieldQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(
first: 100
orderBy: { direction: ASC, field: NAME }
) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
level?: number;
};
type Props<T extends FieldValues = FieldValues> = {
@@ -42,29 +64,47 @@ export function ThirdPartiesMultiSelectField<T extends FieldValues = FieldValues
selectedThirdParties = [],
...props
}: Props<T>) {
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery);
useEffect(() => {
loadQuery({ organizationId }, { fetchPolicy: "network-only" });
}, [loadQuery, organizationId]);
const loadingState = (
<Select variant="editor" disabled placeholder="Loading..." />
);
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<ThirdPartiesMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
selectedThirdParties={selectedThirdParties}
/>
</Suspense>
{queryRef
? (
<Suspense fallback={loadingState}>
<ThirdPartiesMultiSelectWithQuery
queryRef={queryRef}
control={control}
name={props.name}
disabled={props.disabled}
selectedThirdParties={selectedThirdParties}
/>
</Suspense>
)
: (
loadingState
)}
</Field>
);
}
function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedThirdParties">,
props: Pick<Props<T>, "control" | "name" | "disabled" | "selectedThirdParties"> & {
queryRef: PreloadedQuery<ThirdPartiesMultiSelectFieldQuery>;
},
) {
const { __ } = useTranslate();
const { name, organizationId, control, selectedThirdParties = [] } = props;
const thirdParties = useThirdParties(organizationId);
const { name, control, selectedThirdParties = [] } = props;
const data = usePreloadedQuery<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery, props.queryRef);
const thirdParties = data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
const [isOpen, setIsOpen] = useState(false);
const allThirdParties: ThirdParty[] = [...thirdParties];

View File

@@ -14,10 +14,31 @@
import { faviconUrl } from "@probo/helpers";
import { Avatar, Badge, IconCrossLargeX } from "@probo/ui";
import { graphql } from "relay-runtime";
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
import type { ThirdPartiesCellQuery } from "#/__generated__/core/ThirdPartiesCellQuery.graphql";
import { GraphQLCell } from "#/components/table/GraphQLCell";
import { thirdPartiesSelectQuery } from "#/hooks/graph/ThirdPartyGraph";
const thirdPartiesCellQuery = graphql`
query ThirdPartiesCellQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(
first: 100
orderBy: { direction: ASC, field: NAME }
) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
type ThirdParty = {
id: string;
@@ -35,15 +56,19 @@ const empty = [] as ThirdParty[];
export function ThirdPartiesCell(props: Props) {
return (
<GraphQLCell<ThirdPartyGraphSelectQuery, ThirdParty>
<GraphQLCell<ThirdPartiesCellQuery, ThirdParty>
multiple
name={props.name}
query={thirdPartiesSelectQuery}
query={thirdPartiesCellQuery}
variables={{
organizationId: props.organizationId,
}}
items={data =>
data.organization?.thirdParties?.edges?.map(edge => edge.node) ?? []}
data.organization?.thirdParties?.edges?.map(edge => ({
id: edge.node.id,
name: edge.node.name,
websiteUrl: edge.node.websiteUrl,
})) ?? []}
itemRenderer={({ item, onRemove }) => (
<ThirdPartyBadge thirdParty={item} onRemove={onRemove} />
)}

View File

@@ -1,258 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { useMemo } from "react";
import { useLazyLoadQuery, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { ThirdPartyGraphCreateMutation } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import type { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql";
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
const createThirdPartyMutation = graphql`
mutation ThirdPartyGraphCreateMutation(
$input: CreateThirdPartyInput!
$connections: [ID!]!
) {
createThirdParty(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
name
description
websiteUrl
createdAt
updatedAt
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
}
}
}
}
`;
export function useCreateThirdPartyMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(createThirdPartyMutation, {
successMessage: __("Third party created successfully"),
errorMessage: __("Failed to create third party"),
});
}
const deleteThirdPartyMutation = graphql`
mutation ThirdPartyGraphDeleteMutation(
$input: DeleteThirdPartyInput!
$connections: [ID!]!
) {
deleteThirdParty(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteThirdParty = (
thirdParty: { id?: string; name?: string },
connectionId: string,
) => {
const [mutate] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!thirdParty.id || !thirdParty.name) {
return alert(__("Failed to delete third party: missing id or name"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
thirdPartyId: thirdParty.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete thirdParty \"%s\". This action cannot be undone.",
),
thirdParty.name,
),
},
);
};
};
export const thirdPartyConnectionKey = "ThirdPartiesPage_thirdParties";
export const thirdPartiesQuery = graphql`
query ThirdPartyGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateThirdParty: permission(action: "core:thirdParty:create")
canPublishThirdParty: permission(action: "core:thirdParty:publish")
thirdPartiesDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
...ThirdPartyGraphPaginatedFragment
}
}
}
`;
export const paginatedThirdPartiesFragment = graphql`
fragment ThirdPartyGraphPaginatedFragment on Organization
@refetchable(queryName: "ThirdPartiesListQuery")
@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 }
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
) {
thirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: $filter
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
__id
edges {
node {
id
name
websiteUrl
level
updatedAt
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
id
createdAt
expiresAt
dataSensitivity
businessImpact
}
}
}
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
}
}
}
}
`;
export const thirdPartyNodeQuery = graphql`
query ThirdPartyGraphNodeQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
id
... on ThirdParty {
name
websiteUrl
level
ancestors {
id
name
}
vettingStatus
canVet: permission(action: "core:thirdParty:vet")
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
canUploadComplianceReport: permission(
action: "core:thirdParty-compliance-report:upload"
)
canCreateRiskAssessment: permission(
action: "core:thirdParty-risk-assessment:create"
)
canCreateContact: permission(action: "core:thirdParty-contact:create")
canCreateService: permission(action: "core:thirdParty-service:create")
canUploadBAA: permission(
action: "core:thirdParty-business-associate-agreement:upload"
)
canUploadDPA: permission(
action: "core:thirdParty-data-privacy-agreement:upload"
)
measuresInfos: measures(first: 0) {
totalCount
}
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyContactsTabFragment
...ThirdPartyServicesTabFragment
...ThirdPartyRiskAssessmentTabFragment
...ThirdPartyOverviewTabBusinessAssociateAgreementFragment
...ThirdPartyOverviewTabDataPrivacyAgreementFragment
...ThirdPartyMeasuresPageFragment
}
}
viewer {
id
}
}
`;
export const thirdPartiesSelectQuery = graphql`
query ThirdPartyGraphSelectQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(
first: 100
orderBy: { direction: ASC, field: NAME }
) {
edges {
node {
id
name
websiteUrl
level
}
}
}
}
}
}
`;
export function useThirdParties(organizationId: string) {
const data = useLazyLoadQuery<ThirdPartyGraphSelectQuery>(
thirdPartiesSelectQuery,
{
organizationId: organizationId,
},
{ fetchPolicy: "network-only" },
);
return useMemo(() => {
return data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
}, [data]);
}

View File

@@ -12,83 +12,131 @@
// 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 {
ActionDropdown,
Avatar,
Button,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
RiskBadge,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@probo/ui";
import {
graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useNavigate } from "react-router";
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
import type {
ThirdPartyGraphPaginatedFragment$data,
ThirdPartyGraphPaginatedFragment$key,
} from "#/__generated__/core/ThirdPartyGraphPaginatedFragment.graphql";
import type { ThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartiesPageFragment.graphql";
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
import type { ThirdPartiesPageRefetchQuery } from "#/__generated__/core/ThirdPartiesPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
paginatedThirdPartiesFragment,
thirdPartiesQuery,
useDeleteThirdParty,
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { CreateThirdPartyDialog } from "./dialogs/CreateThirdPartyDialog";
import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDialog";
import { CreateThirdPartyDialog } from "./_components/CreateThirdPartyDialog";
import { PublishThirdPartyListDialog } from "./_components/PublishThirdPartyListDialog";
import { ThirdPartyRow } from "./_components/ThirdPartyRow";
type ThirdParty = NodeOf<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
export const thirdPartiesPageQuery = graphql`
query ThirdPartiesPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...ThirdPartiesPageFragment
}
}
`;
function thirdPartyDisplayName(tp: ThirdParty): string {
// Sub-third-parties are persisted with their fully-qualified name
// (e.g. "aws (Probo/Level2/Level3)"), so the stored name is shown as-is.
return tp.name;
const thirdPartiesFragment = graphql`
fragment ThirdPartiesPageFragment on Organization
@refetchable(queryName: "ThirdPartiesPageRefetchQuery")
@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 }
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
) {
canCreateThirdParty: permission(action: "core:thirdParty:create")
canPublishThirdParty: permission(action: "core:thirdParty:publish")
thirdPartiesDocument {
id
defaultApprovers {
id
}
}
thirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: $filter
) @connection(key: "ThirdPartiesPage_thirdParties", filters: ["filter"]) {
__id
edges {
node {
id
canDelete: permission(action: "core:thirdParty:delete")
...ThirdPartyRow_thirdParty
}
}
}
}
`;
export const ThirdPartiesConnectionKey = "ThirdPartiesPage_thirdParties";
// Must match the `filter` default of `ThirdPartiesPageFragment` above — the
// connection is keyed on this filter (`@connection(filters: ["filter"])`), so
// deriving its id elsewhere requires the same value.
export const ThirdPartiesConnectionFilter = { level: 1 };
interface ThirdPartiesPageProps {
queryRef: PreloadedQuery<ThirdPartiesPageQuery>;
}
type Props = {
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
};
export default function ThirdPartiesPage(props: Props) {
export default function ThirdPartiesPage(props: ThirdPartiesPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const data = usePreloadedQuery<ThirdPartyGraphListQuery>(thirdPartiesQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types
const pagination = usePaginationFragment(
paginatedThirdPartiesFragment,
data.node as ThirdPartyGraphPaginatedFragment$key,
);
const queryData = usePreloadedQuery<ThirdPartiesPageQuery>(thirdPartiesPageQuery, props.queryRef);
const { data: fragmentData, ...pagination } = usePaginationFragment<
ThirdPartiesPageRefetchQuery,
ThirdPartiesPageFragment$key
>(thirdPartiesFragment, queryData.organization);
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id;
const refetch = ({
order,
}: {
order: { direction: string; field: string };
}) => {
pagination.refetch(
{
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "NAME" | "CREATED_AT" | "UPDATED_AT",
},
},
{ fetchPolicy: "network-only" },
);
};
const thirdParties = fragmentData.thirdParties?.edges.map(edge => edge.node) ?? [];
const connectionId = fragmentData.thirdParties.__id;
usePageTitle(__("Third parties"));
const hasAnyAction
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
const hasAnyAction = thirdParties.some(({ canDelete }) => canDelete);
const thirdPartiesDocument = data.node?.thirdPartiesDocument;
const thirdPartiesDocument = fragmentData.thirdPartiesDocument;
const defaultApproverIds
= thirdPartiesDocument?.defaultApprovers?.map(a => a.id) ?? [];
@@ -112,7 +160,7 @@ export default function ThirdPartiesPage(props: Props) {
{__("Document")}
</Button>
)}
{data.node.canPublishThirdParty && (
{fragmentData.canPublishThirdParty && (
<PublishThirdPartyListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
@@ -125,7 +173,7 @@ export default function ThirdPartiesPage(props: Props) {
</Button>
</PublishThirdPartyListDialog>
)}
{data.node.canCreateThirdParty && (
{fragmentData.canCreateThirdParty && (
<CreateThirdPartyDialog
connection={connectionId}
organizationId={organizationId}
@@ -135,7 +183,7 @@ export default function ThirdPartiesPage(props: Props) {
)}
</div>
</PageHeader>
<SortableTable {...pagination}>
<SortableTable {...pagination} refetch={refetch}>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Third party")}</SortableTh>
@@ -146,11 +194,10 @@ export default function ThirdPartiesPage(props: Props) {
</Tr>
</Thead>
<Tbody>
{thirdParties?.map(thirdParty => (
{thirdParties.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
organizationId={organizationId}
thirdPartyKey={thirdParty}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
@@ -160,61 +207,3 @@ export default function ThirdPartiesPage(props: Props) {
</div>
);
}
function ThirdPartyRow({
thirdParty,
organizationId,
connectionId,
hasAnyAction,
}: {
thirdParty: ThirdParty;
organizationId: string;
connectionId: string;
hasAnyAction: boolean;
}) {
const { __ } = useTranslate();
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
const displayName = thirdPartyDisplayName(thirdParty);
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
return (
<>
<Tr to={thirdPartyUrl}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
<div>{displayName}</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>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{thirdParty.canDelete && (
<DropdownItem
onClick={deleteThirdParty}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
</>
);
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import ThirdPartiesPage, { thirdPartiesPageQuery } from "./ThirdPartiesPage";
export default function ThirdPartiesPageLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartiesPageQuery>(thirdPartiesPageQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<ThirdPartiesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -12,7 +12,8 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl } from "@probo/helpers";
import { faviconUrl, formatError, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -25,59 +26,113 @@ import {
TabBadge,
TabLink,
Tabs,
useConfirm,
useToast,
} from "@probo/ui";
import { useEffect, useRef } from "react";
import {
ConnectionHandler,
graphql,
type PreloadedQuery,
useFragment,
useMutation,
usePreloadedQuery,
useRelayEnvironment,
} from "react-relay";
import { Link, Outlet } from "react-router";
import { fetchQuery } from "relay-runtime";
import { Link, Outlet, useNavigate, useParams } from "react-router";
import { ConnectionHandler, fetchQuery } from "relay-runtime";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import {
thirdPartyConnectionKey,
thirdPartyNodeQuery,
useDeleteThirdParty,
} from "#/hooks/graph/ThirdPartyGraph";
import type { ThirdPartyDetailLayoutDeleteMutation } from "#/__generated__/core/ThirdPartyDetailLayoutDeleteMutation.graphql";
import type { ThirdPartyDetailLayoutQuery } from "#/__generated__/core/ThirdPartyDetailLayoutQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { VettingDialog } from "./dialogs/VettingDialog";
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
import { VettingDialog } from "./_components/VettingDialog";
import { ThirdPartiesConnectionFilter, ThirdPartiesConnectionKey } from "./ThirdPartiesPage";
void measuresFragment;
export const thirdPartyDetailLayoutQuery = graphql`
query ThirdPartyDetailLayoutQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
id
name
websiteUrl
level
ancestors {
id
name
}
vettingStatus
canVet: permission(action: "core:thirdParty:vet")
canDelete: permission(action: "core:thirdParty:delete")
complianceReportsInfo: complianceReports(first: 100) {
edges {
node {
id
}
}
}
measuresInfo: measures(first: 0) {
totalCount
}
}
}
}
`;
type Props = {
queryRef: PreloadedQuery<ThirdPartyGraphNodeQuery>;
};
const deleteThirdPartyMutation = graphql`
mutation ThirdPartyDetailLayoutDeleteMutation(
$input: DeleteThirdPartyInput!
$connections: [ID!]!
) {
deleteThirdParty(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
export default function ThirdPartyDetailPage(props: Props) {
interface ThirdPartyDetailLayoutProps {
queryRef: PreloadedQuery<ThirdPartyDetailLayoutQuery>;
}
export default function ThirdPartyDetailLayout(props: ThirdPartyDetailLayoutProps) {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const environment = useRelayEnvironment();
const { node: thirdParty } = usePreloadedQuery<ThirdPartyGraphNodeQuery>(thirdPartyNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const thirdPartyIdRef = useRef(thirdParty.id);
const navigate = useNavigate();
const { __ } = useTranslate();
const confirm = useConfirm();
const { toast } = useToast();
if (!thirdPartyId) {
throw new Error("Cannot load third party detail layout without thirdPartyId parameter");
}
const data = usePreloadedQuery<ThirdPartyDetailLayoutQuery>(thirdPartyDetailLayoutQuery, props.queryRef);
if (data.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const thirdParty = data.node;
const thirdPartyIdRef = useRef(thirdParty.id);
useEffect(() => {
thirdPartyIdRef.current = thirdParty.id;
}, [thirdParty.id]);
const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING";
const isVetting = thirdParty.vettingStatus === "PENDING"
|| thirdParty.vettingStatus === "PROCESSING";
useEffect(() => {
if (!isVetting) return;
if (!isVetting) {
return;
}
const interval = setInterval(() => {
if (document.hidden) return;
if (document.hidden) {
return;
}
fetchQuery<ThirdPartyGraphNodeQuery>(
fetchQuery(
environment,
thirdPartyNodeQuery,
thirdPartyDetailLayoutQuery,
{ thirdPartyId: thirdPartyIdRef.current },
{ fetchPolicy: "network-only" },
).subscribe({});
@@ -86,26 +141,67 @@ export default function ThirdPartyDetailPage(props: Props) {
return () => clearInterval(interval);
}, [isVetting, environment]);
const deleteThirdParty = useDeleteThirdParty(
thirdParty,
ConnectionHandler.getConnectionID(organizationId, thirdPartyConnectionKey),
const [deleteThirdParty] = useMutation<ThirdPartyDetailLayoutDeleteMutation>(
deleteThirdPartyMutation,
);
usePageTitle(thirdParty.name ?? __("Third party"));
const onDelete = () => {
if (!thirdParty.name) {
return;
}
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
ThirdPartiesConnectionKey,
{ filter: ThirdPartiesConnectionFilter },
);
confirm(
() =>
new Promise<void>((resolve) => {
void deleteThirdParty({
variables: {
input: { thirdPartyId: thirdParty.id },
connections: [connectionId],
},
onCompleted() {
void navigate(`/organizations/${organizationId}/third-parties`);
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete third party"),
error,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the third party \"%s\". This action cannot be undone.",
),
thirdParty.name,
),
},
);
};
const logo = faviconUrl(thirdParty.websiteUrl);
const reportsCount = useFragment(
complianceReportsFragment,
thirdParty as ThirdPartyComplianceTabFragment$key,
).complianceReports.edges.length;
const measuresCount = thirdParty.measuresInfos?.totalCount ?? 0;
const reportsCount = thirdParty.complianceReportsInfo?.edges.length ?? 0;
const measuresCount = thirdParty.measuresInfo?.totalCount ?? 0;
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
const ancestors = thirdParty.ancestors ?? [];
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
const baseThirdPartyUrl
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
const ancestors = thirdParty.ancestors ?? [];
return (
<div className="space-y-6">
{isVetting && (
@@ -181,7 +277,7 @@ export default function ThirdPartyDetailPage(props: Props) {
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteThirdParty}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
@@ -211,7 +307,7 @@ export default function ThirdPartyDetailPage(props: Props) {
</TabLink>
</Tabs>
<Outlet context={{ thirdParty }} />
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyDetailLayoutQuery } from "#/__generated__/core/ThirdPartyDetailLayoutQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import ThirdPartyDetailLayout, { thirdPartyDetailLayoutQuery } from "./ThirdPartyDetailLayout";
export default function ThirdPartyDetailLayoutLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyDetailLayoutQuery>(thirdPartyDetailLayoutQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<ThirdPartyDetailLayout queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -27,9 +27,11 @@ import { useMutation, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
import type {
AddChildThirdPartyDialogCreateMutation,
CreateThirdPartyInput,
} from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";

View File

@@ -22,7 +22,7 @@ import type {
CommonThirdPartyCombobox_commonThirdParty$key,
} from "#/__generated__/core/CommonThirdPartyCombobox_commonThirdParty.graphql";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/CreateThirdPartyDialogCreateMutation.graphql";
export type CommonThirdPartyRef
= CommonThirdPartyCombobox_commonThirdParty$data;

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { cleanFormData } from "@probo/helpers";
import { cleanFormData, formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
@@ -22,13 +22,15 @@ import {
DialogFooter,
Field,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CreateContactDialogMutation } from "#/__generated__/core/CreateContactDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
@@ -46,7 +48,7 @@ const createContactMutation = graphql`
node {
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactsTabFragment_contact
...ThirdPartyContactRow_contact
}
}
}
@@ -93,18 +95,15 @@ export function CreateContactDialog({
},
},
);
const [createContact, isLoading] = useMutationWithToasts(
const { toast } = useToast();
const [createContact, isCreating] = useMutation<CreateContactDialogMutation>(
createContactMutation,
{
successMessage: __("Contact created successfully."),
errorMessage: __("Failed to create contact"),
},
);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const cleanData = cleanFormData(data);
await createContact({
createContact({
variables: {
input: {
thirdPartyId,
@@ -112,10 +111,30 @@ export function CreateContactDialog({
},
connections: [connectionId],
},
onSuccess: () => {
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(__("Failed to create contact"), errors),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Contact created successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create contact"), error),
variant: "error",
});
},
});
};
@@ -161,7 +180,7 @@ export function CreateContactDialog({
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isCreating}>
{__("Create")}
</Button>
</DialogFooter>

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
@@ -23,14 +24,16 @@ import {
ImpactOptions,
SentitivityOptions,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CreateRiskAssessmentDialogMutation } from "#/__generated__/core/CreateRiskAssessmentDialogMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
@@ -46,7 +49,7 @@ const createRiskAssessmentMutation = graphql`
createThirdPartyRiskAssessment(input: $input) {
thirdPartyRiskAssessmentEdge @prependEdge(connections: $connections) {
node {
...ThirdPartyRiskAssessmentTabFragment_assessment
...ThirdPartyRiskAssessmentRow_assessment
}
}
}
@@ -76,18 +79,16 @@ export function CreateRiskAssessmentDialog({
businessImpact: "LOW",
},
});
const [createRiskAssessment, isLoading] = useMutationWithToasts(
createRiskAssessmentMutation,
{
successMessage: __("Risk Assessment created successfully."),
errorMessage: __("Failed to create Risk Assessment"),
},
);
const { toast } = useToast();
const [createRiskAssessment, isCreating]
= useMutation<CreateRiskAssessmentDialogMutation>(
createRiskAssessmentMutation,
);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const nextYear = new Date();
nextYear.setFullYear(nextYear.getFullYear() + 1);
await createRiskAssessment({
createRiskAssessment({
variables: {
input: {
...data,
@@ -97,10 +98,36 @@ export function CreateRiskAssessmentDialog({
},
connections: [connection],
},
onSuccess: () => {
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to create Risk Assessment"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Risk Assessment created successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to create Risk Assessment"),
error,
),
variant: "error",
});
},
});
};
@@ -146,7 +173,7 @@ export function CreateRiskAssessmentDialog({
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isCreating}>
{__("Create")}
</Button>
</DialogFooter>

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { cleanFormData } from "@probo/helpers";
import { cleanFormData, formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
@@ -22,13 +22,15 @@ import {
DialogFooter,
Field,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CreateServiceDialogMutation } from "#/__generated__/core/CreateServiceDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
@@ -44,7 +46,7 @@ const createServiceMutation = graphql`
createThirdPartyService(input: $input) {
thirdPartyServiceEdge @prependEdge(connections: $connections) {
node {
...ThirdPartyServicesTabFragment_service
...ThirdPartyServiceRow_service
}
}
}
@@ -72,18 +74,15 @@ export function CreateServiceDialog({
},
},
);
const [createService, isLoading] = useMutationWithToasts(
const { toast } = useToast();
const [createService, isCreating] = useMutation<CreateServiceDialogMutation>(
createServiceMutation,
{
successMessage: __("Service created successfully."),
errorMessage: __("Failed to create service"),
},
);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const cleanData = cleanFormData(data);
await createService({
createService({
variables: {
input: {
thirdPartyId,
@@ -91,10 +90,30 @@ export function CreateServiceDialog({
},
connections: [connectionId],
},
onSuccess: () => {
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(__("Failed to create service"), errors),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Service created successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create service"), error),
variant: "error",
});
},
});
};
@@ -128,7 +147,7 @@ export function CreateServiceDialog({
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isCreating}>
{__("Create")}
</Button>
</DialogFooter>

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Combobox,
@@ -21,17 +22,38 @@ import {
DialogFooter,
IconPlusLarge,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode, Suspense, useCallback, useState } from "react";
import { useQueryLoader } from "react-relay";
import { useMutation, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
import type {
CreateThirdPartyDialogCreateMutation,
CreateThirdPartyInput,
} from "#/__generated__/core/CreateThirdPartyDialogCreateMutation.graphql";
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
const createThirdPartyMutation = graphql`
mutation CreateThirdPartyDialogCreateMutation(
$input: CreateThirdPartyInput!
$connections: [ID!]!
) {
createThirdParty(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
canDelete: permission(action: "core:thirdParty:delete")
...ThirdPartyRow_thirdParty
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
@@ -44,7 +66,8 @@ export function CreateThirdPartyDialog({
connection,
}: Props) {
const { __ } = useTranslate();
const [createThirdParty] = useCreateThirdPartyMutation();
const { toast } = useToast();
const [createThirdParty] = useMutation<CreateThirdPartyDialogCreateMutation>(createThirdPartyMutation);
const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery]
@@ -62,14 +85,34 @@ export function CreateThirdPartyDialog({
...thirdParty,
organizationId,
};
void createThirdParty({
createThirdParty({
variables: {
input,
connections: [connection],
},
onSuccess: () => {
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(__("Failed to create third party"), errors),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Third party created successfully"),
variant: "success",
});
dialogRef.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create third party"), error),
variant: "error",
});
},
});
};
@@ -100,12 +143,12 @@ export function CreateThirdPartyDialog({
<CommonThirdPartyCombobox
queryRef={queryRef}
excludeNames={new Set()}
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
onSelect={onSelect}
/>
</Suspense>
)}
{searchQuery.trim().length >= 2 && (
<ComboboxItem onClick={() => void onSelect(searchQuery.trim())}>
<ComboboxItem onClick={() => onSelect(searchQuery.trim())}>
<IconPlusLarge size={20} />
{__("Create a new third party")}
{" "}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { sprintf } from "@probo/helpers";
import { formatError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -21,10 +21,11 @@ import {
DialogFooter,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import type { DeleteBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/DeleteBusinessAssociateAgreementDialogMutation.graphql";
const deleteBusinessAssociateAgreementMutation = graphql`
mutation DeleteBusinessAssociateAgreementDialogMutation(
@@ -52,22 +53,50 @@ export function DeleteBusinessAssociateAgreementDialog({
const { __ } = useTranslate();
const ref = useDialogRef();
const [mutate, isDeleting] = useMutationWithToasts(deleteBusinessAssociateAgreementMutation, {
successMessage: __("Business Associate Agreement deleted successfully"),
errorMessage: __("Failed to delete Business Associate Agreement"),
});
const { toast } = useToast();
const [deleteAgreement, isDeleting]
= useMutation<DeleteBusinessAssociateAgreementDialogMutation>(
deleteBusinessAssociateAgreementMutation,
);
const handleDelete = async () => {
await mutate({
const handleDelete = () => {
deleteAgreement({
variables: {
input: {
thirdPartyId,
},
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete Business Associate Agreement"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Business Associate Agreement deleted successfully"),
variant: "success",
});
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete Business Associate Agreement"),
error,
),
variant: "error",
});
},
});
onSuccess?.();
ref.current?.close();
};
return (
@@ -92,7 +121,7 @@ export function DeleteBusinessAssociateAgreementDialog({
<DialogFooter>
<Button
variant="danger"
onClick={() => void handleDelete()}
onClick={handleDelete}
disabled={isDeleting}
icon={isDeleting ? Spinner : undefined}
>

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { sprintf } from "@probo/helpers";
import { formatError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -21,10 +21,11 @@ import {
DialogFooter,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import type { DeleteDataPrivacyAgreementDialogMutation } from "#/__generated__/core/DeleteDataPrivacyAgreementDialogMutation.graphql";
const deleteDataPrivacyAgreementMutation = graphql`
mutation DeleteDataPrivacyAgreementDialogMutation(
@@ -52,22 +53,50 @@ export function DeleteDataPrivacyAgreementDialog({
const { __ } = useTranslate();
const ref = useDialogRef();
const [mutate, isDeleting] = useMutationWithToasts(deleteDataPrivacyAgreementMutation, {
successMessage: __("Data Privacy Agreement deleted successfully"),
errorMessage: __("Failed to delete Data Privacy Agreement"),
});
const { toast } = useToast();
const [deleteAgreement, isDeleting]
= useMutation<DeleteDataPrivacyAgreementDialogMutation>(
deleteDataPrivacyAgreementMutation,
);
const handleDelete = async () => {
await mutate({
const handleDelete = () => {
deleteAgreement({
variables: {
input: {
thirdPartyId,
},
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete Data Privacy Agreement"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Data Privacy Agreement deleted successfully"),
variant: "success",
});
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete Data Privacy Agreement"),
error,
),
variant: "error",
});
},
});
onSuccess?.();
ref.current?.close();
};
return (
@@ -92,7 +121,7 @@ export function DeleteDataPrivacyAgreementDialog({
<DialogFooter>
<Button
variant="danger"
onClick={() => void handleDelete()}
onClick={handleDelete}
disabled={isDeleting}
icon={isDeleting ? Spinner : undefined}
>

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -22,12 +23,13 @@ import {
Input,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { z } from "zod";
import type { EditBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/EditBusinessAssociateAgreementDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateBusinessAssociateAgreementMutation = graphql`
mutation EditBusinessAssociateAgreementDialogMutation(
@@ -79,7 +81,6 @@ export function EditBusinessAssociateAgreementDialog({
const {
register,
handleSubmit,
formState: { isSubmitting },
reset,
} = useFormWithSchema(schema, {
defaultValues: {
@@ -88,18 +89,19 @@ export function EditBusinessAssociateAgreementDialog({
},
});
const [mutate] = useMutationWithToasts(updateBusinessAssociateAgreementMutation, {
successMessage: __("Business Associate Agreement updated successfully"),
errorMessage: __("Failed to update Business Associate Agreement"),
});
const { toast } = useToast();
const [updateAgreement, isUpdating]
= useMutation<EditBusinessAssociateAgreementDialogMutation>(
updateBusinessAssociateAgreementMutation,
);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const formatDatetime = (dateString?: string) => {
if (!dateString) return null;
return `${dateString}T00:00:00Z`;
};
await mutate({
updateAgreement({
variables: {
input: {
thirdPartyId,
@@ -107,10 +109,37 @@ export function EditBusinessAssociateAgreementDialog({
validUntil: formatDatetime(data.validUntil),
},
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update Business Associate Agreement"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Business Associate Agreement updated successfully"),
variant: "success",
});
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update Business Associate Agreement"),
error,
),
variant: "error",
});
},
});
onSuccess?.();
ref.current?.close();
};
const handleClose = () => {
@@ -140,8 +169,8 @@ export function EditBusinessAssociateAgreementDialog({
<DialogFooter>
<Button
type="submit"
disabled={isSubmitting}
icon={isSubmitting ? Spinner : undefined}
disabled={isUpdating}
icon={isUpdating ? Spinner : undefined}
>
{__("Update")}
</Button>

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { cleanFormData } from "@probo/helpers";
import { cleanFormData, formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
@@ -22,26 +22,37 @@ import {
DialogFooter,
Field,
useDialogRef,
useToast,
} from "@probo/ui";
import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { graphql, useFragment, useMutation } from "react-relay";
import { z } from "zod";
import type { ThirdPartyContactsTabFragment_contact$data } from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
import type { EditContactDialog_contact$key } from "#/__generated__/core/EditContactDialog_contact.graphql";
import type { EditContactDialogUpdateMutation } from "#/__generated__/core/EditContactDialogUpdateMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
contactId: string;
contact: ThirdPartyContactsTabFragment_contact$data;
contactKey: EditContactDialog_contact$key;
onClose: () => void;
};
const editContactDialogFragment = graphql`
fragment EditContactDialog_contact on ThirdPartyContact {
id
fullName
email
phone
role
}
`;
const updateContactMutation = graphql`
mutation EditContactDialogUpdateMutation($input: UpdateThirdPartyContactInput!) {
updateThirdPartyContact(input: $input) {
thirdPartyContact {
...ThirdPartyContactsTabFragment_contact
...ThirdPartyContactRow_contact
...EditContactDialog_contact
}
}
}
@@ -49,8 +60,10 @@ const updateContactMutation = graphql`
const phoneRegex = /^\+[0-9]{8,15}$/;
export function EditContactDialog({ contactId, contact, onClose }: Props) {
export function EditContactDialog({ contactKey, onClose }: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const contact = useFragment(editContactDialogFragment, contactKey);
const schema = z.object({
fullName: z.string().optional(),
@@ -81,27 +94,42 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
},
});
const [updateContact, isLoading] = useMutationWithToasts(
updateContactMutation,
{
successMessage: __("Contact updated successfully."),
errorMessage: __("Failed to update contact"),
},
);
const [updateContact, isUpdating]
= useMutation<EditContactDialogUpdateMutation>(updateContactMutation);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const cleanData = cleanFormData(data);
await updateContact({
updateContact({
variables: {
input: {
id: contactId,
id: contact.id,
...cleanData,
},
},
onSuccess: () => {
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(__("Failed to update contact"), errors),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Contact updated successfully."),
variant: "success",
});
onClose();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to update contact"), error),
variant: "error",
});
},
});
};
@@ -151,7 +179,7 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isUpdating}>
{__("Save")}
</Button>
</DialogFooter>

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -22,12 +23,13 @@ import {
Input,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { z } from "zod";
import type { EditDataPrivacyAgreementDialogMutation } from "#/__generated__/core/EditDataPrivacyAgreementDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateDataPrivacyAgreementMutation = graphql`
mutation EditDataPrivacyAgreementDialogMutation(
@@ -79,7 +81,6 @@ export function EditDataPrivacyAgreementDialog({
const {
register,
handleSubmit,
formState: { isSubmitting },
reset,
} = useFormWithSchema(schema, {
defaultValues: {
@@ -88,18 +89,19 @@ export function EditDataPrivacyAgreementDialog({
},
});
const [mutate] = useMutationWithToasts(updateDataPrivacyAgreementMutation, {
successMessage: __("Data Privacy Agreement updated successfully"),
errorMessage: __("Failed to update Data Privacy Agreement"),
});
const { toast } = useToast();
const [updateAgreement, isUpdating]
= useMutation<EditDataPrivacyAgreementDialogMutation>(
updateDataPrivacyAgreementMutation,
);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const formatDatetime = (dateString?: string) => {
if (!dateString) return null;
return `${dateString}T00:00:00Z`;
};
await mutate({
updateAgreement({
variables: {
input: {
thirdPartyId,
@@ -107,10 +109,37 @@ export function EditDataPrivacyAgreementDialog({
validUntil: formatDatetime(data.validUntil),
},
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update Data Privacy Agreement"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Data Privacy Agreement updated successfully"),
variant: "success",
});
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update Data Privacy Agreement"),
error,
),
variant: "error",
});
},
});
onSuccess?.();
ref.current?.close();
};
const handleClose = () => {
@@ -140,8 +169,8 @@ export function EditDataPrivacyAgreementDialog({
<DialogFooter>
<Button
type="submit"
disabled={isSubmitting}
icon={isSubmitting ? Spinner : undefined}
disabled={isUpdating}
icon={isUpdating ? Spinner : undefined}
>
{__("Update")}
</Button>

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { cleanFormData } from "@probo/helpers";
import { cleanFormData, formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
@@ -22,35 +22,44 @@ import {
DialogFooter,
Field,
useDialogRef,
useToast,
} from "@probo/ui";
import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { graphql, useFragment, useMutation } from "react-relay";
import { z } from "zod";
import type { EditServiceDialog_service$key } from "#/__generated__/core/EditServiceDialog_service.graphql";
import type { EditServiceDialogUpdateMutation } from "#/__generated__/core/EditServiceDialogUpdateMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
serviceId: string;
service: {
name: string;
description?: string | null;
};
serviceKey: EditServiceDialog_service$key;
onClose: () => void;
};
const editServiceDialogFragment = graphql`
fragment EditServiceDialog_service on ThirdPartyService {
id
name
description
}
`;
const updateServiceMutation = graphql`
mutation EditServiceDialogUpdateMutation($input: UpdateThirdPartyServiceInput!) {
updateThirdPartyService(input: $input) {
thirdPartyService {
...ThirdPartyServicesTabFragment_service
...ThirdPartyServiceRow_service
...EditServiceDialog_service
}
}
}
`;
export function EditServiceDialog({ serviceId, service, onClose }: Props) {
export function EditServiceDialog({ serviceKey, onClose }: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const service = useFragment(editServiceDialogFragment, serviceKey);
const schema = z.object({
name: z.string().min(1, __("Service name is required")),
@@ -67,27 +76,42 @@ export function EditServiceDialog({ serviceId, service, onClose }: Props) {
},
);
const [updateService, isLoading] = useMutationWithToasts(
updateServiceMutation,
{
successMessage: __("Service updated successfully."),
errorMessage: __("Failed to update service"),
},
);
const [updateService, isUpdating]
= useMutation<EditServiceDialogUpdateMutation>(updateServiceMutation);
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
const cleanData = cleanFormData(data);
await updateService({
updateService({
variables: {
input: {
id: serviceId,
id: service.id,
...cleanData,
},
},
onSuccess: () => {
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(__("Failed to update service"), errors),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Service updated successfully."),
variant: "success",
});
onClose();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to update service"), error),
variant: "error",
});
},
});
};
@@ -125,7 +149,7 @@ export function EditServiceDialog({ serviceId, service, onClose }: Props) {
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
<Button type="submit" disabled={isUpdating}>
{__("Save")}
</Button>
</DialogFooter>

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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, formatError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
DropdownItem,
IconTrashCan,
RiskBadge,
Td,
Tr,
useConfirm,
useToast,
} from "@probo/ui";
import { graphql, useFragment, useMutation } from "react-relay";
import type { ThirdPartyRow_thirdParty$key } from "#/__generated__/core/ThirdPartyRow_thirdParty.graphql";
import type { ThirdPartyRowDeleteMutation } from "#/__generated__/core/ThirdPartyRowDeleteMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const thirdPartyRowFragment = graphql`
fragment ThirdPartyRow_thirdParty on ThirdParty {
id
name
websiteUrl
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
createdAt
dataSensitivity
businessImpact
}
}
}
canDelete: permission(action: "core:thirdParty:delete")
}
`;
const deleteThirdPartyMutation = graphql`
mutation ThirdPartyRowDeleteMutation(
$input: DeleteThirdPartyInput!
$connections: [ID!]!
) {
deleteThirdParty(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
interface ThirdPartyRowProps {
thirdPartyKey: ThirdPartyRow_thirdParty$key;
connectionId: string;
hasAnyAction: boolean;
}
export function ThirdPartyRow(props: ThirdPartyRowProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const thirdParty = useFragment(thirdPartyRowFragment, props.thirdPartyKey);
const [deleteThirdParty] = useMutation<ThirdPartyRowDeleteMutation>(
deleteThirdPartyMutation,
);
const confirm = useConfirm();
const { toast } = useToast();
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
const onDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
void deleteThirdParty({
variables: {
input: { thirdPartyId: thirdParty.id },
connections: [props.connectionId],
},
onCompleted() {
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete third party"),
error,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the third party \"%s\". This action cannot be undone.",
),
thirdParty.name || __("Unnamed third party"),
),
},
);
};
return (
<Tr to={thirdPartyUrl}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
<div>{thirdParty.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>
{props.hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{thirdParty.canDelete && (
<DropdownItem
onClick={onDelete}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -23,13 +24,14 @@ import {
Input,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { z } from "zod";
import type { UploadBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/UploadBusinessAssociateAgreementDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadBusinessAssociateAgreementMutation = graphql`
mutation UploadBusinessAssociateAgreementDialogMutation(
@@ -74,7 +76,7 @@ export function UploadBusinessAssociateAgreementDialog({
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
formState: { errors },
reset,
setValue,
} = useFormWithSchema(schema, {
@@ -85,10 +87,11 @@ export function UploadBusinessAssociateAgreementDialog({
},
});
const [mutate] = useMutationWithToasts(uploadBusinessAssociateAgreementMutation, {
successMessage: __("Business Associate Agreement uploaded successfully"),
errorMessage: __("Failed to upload Business Associate Agreement"),
});
const { toast } = useToast();
const [uploadAgreement, isUploading]
= useMutation<UploadBusinessAssociateAgreementDialogMutation>(
uploadBusinessAssociateAgreementMutation,
);
const handleDrop = (files: File[]) => {
if (files.length > 0) {
@@ -98,7 +101,7 @@ export function UploadBusinessAssociateAgreementDialog({
}
};
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
if (!uploadedFile) {
return;
}
@@ -108,7 +111,7 @@ export function UploadBusinessAssociateAgreementDialog({
return `${dateString}T00:00:00Z`;
};
await mutate({
uploadAgreement({
variables: {
input: {
thirdPartyId,
@@ -121,12 +124,39 @@ export function UploadBusinessAssociateAgreementDialog({
uploadables: {
"input.file": uploadedFile,
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to upload Business Associate Agreement"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Business Associate Agreement uploaded successfully"),
variant: "success",
});
reset();
setUploadedFile(null);
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to upload Business Associate Agreement"),
error,
),
variant: "error",
});
},
});
reset();
setUploadedFile(null);
onSuccess?.();
ref.current?.close();
};
const handleClose = () => {
@@ -146,7 +176,7 @@ export function UploadBusinessAssociateAgreementDialog({
<DialogContent padded className="space-y-4">
<Dropzone
description={__("Only PDF files up to 10MB are allowed")}
isUploading={isSubmitting}
isUploading={isUploading}
onDrop={handleDrop}
accept={{
"application/pdf": [".pdf"],
@@ -186,8 +216,8 @@ export function UploadBusinessAssociateAgreementDialog({
<DialogFooter>
<Button
type="submit"
disabled={isSubmitting || !uploadedFile}
icon={isSubmitting ? Spinner : undefined}
disabled={isUploading || !uploadedFile}
icon={isUploading ? Spinner : undefined}
>
{__("Upload")}
</Button>

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatDatetime, todayAsDateInput } from "@probo/helpers";
import { formatDatetime, formatError, todayAsDateInput } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -24,13 +24,14 @@ import {
Input,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { z } from "zod";
import type { UploadComplianceReportDialogMutation } from "#/__generated__/core/UploadComplianceReportDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadComplianceReportMutation = graphql`
mutation UploadComplianceReportDialogMutation(
@@ -82,7 +83,6 @@ export function UploadComplianceReportDialog({
const {
register,
handleSubmit,
formState: { isSubmitting },
reset,
} = useFormWithSchema(schema, {
defaultValues: {
@@ -91,10 +91,11 @@ export function UploadComplianceReportDialog({
},
});
const [mutate] = useMutationWithToasts(uploadComplianceReportMutation, {
successMessage: __("Compliance report uploaded successfully"),
errorMessage: __("Failed to upload compliance report"),
});
const { toast } = useToast();
const [uploadComplianceReport, isUploading]
= useMutation<UploadComplianceReportDialogMutation>(
uploadComplianceReportMutation,
);
const handleDrop = (files: File[]) => {
if (files.length > 0) {
@@ -102,12 +103,12 @@ export function UploadComplianceReportDialog({
}
};
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
if (!uploadedFile) {
return;
}
await mutate({
uploadComplianceReport({
variables: {
connections: [connectionId],
input: {
@@ -121,12 +122,39 @@ export function UploadComplianceReportDialog({
uploadables: {
"input.file": uploadedFile,
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to upload compliance report"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Compliance report uploaded successfully"),
variant: "success",
});
reset();
setUploadedFile(null);
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to upload compliance report"),
error,
),
variant: "error",
});
},
});
reset();
setUploadedFile(null);
onSuccess?.();
ref.current?.close();
};
const handleClose = () => {
@@ -146,7 +174,7 @@ export function UploadComplianceReportDialog({
<DialogContent padded className="space-y-4">
<Dropzone
description={__("Only PDF files up to 10MB are allowed")}
isUploading={isSubmitting}
isUploading={isUploading}
onDrop={handleDrop}
accept={{
"application/pdf": [".pdf"],
@@ -177,8 +205,8 @@ export function UploadComplianceReportDialog({
<DialogFooter>
<Button
type="submit"
disabled={isSubmitting || !uploadedFile}
icon={isSubmitting ? Spinner : undefined}
disabled={isUploading || !uploadedFile}
icon={isUploading ? Spinner : undefined}
>
{__("Upload")}
</Button>

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -23,13 +24,14 @@ import {
Input,
Spinner,
useDialogRef,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { graphql } from "react-relay";
import { graphql, useMutation } from "react-relay";
import { z } from "zod";
import type { UploadDataPrivacyAgreementDialogMutation } from "#/__generated__/core/UploadDataPrivacyAgreementDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadDataPrivacyAgreementMutation = graphql`
mutation UploadDataPrivacyAgreementDialogMutation(
@@ -74,7 +76,7 @@ export function UploadDataPrivacyAgreementDialog({
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
formState: { errors },
reset,
setValue,
} = useFormWithSchema(schema, {
@@ -85,10 +87,11 @@ export function UploadDataPrivacyAgreementDialog({
},
});
const [mutate] = useMutationWithToasts(uploadDataPrivacyAgreementMutation, {
successMessage: __("Data Privacy Agreement uploaded successfully"),
errorMessage: __("Failed to upload Data Privacy Agreement"),
});
const { toast } = useToast();
const [uploadAgreement, isUploading]
= useMutation<UploadDataPrivacyAgreementDialogMutation>(
uploadDataPrivacyAgreementMutation,
);
const handleDrop = (files: File[]) => {
if (files.length > 0) {
@@ -98,7 +101,7 @@ export function UploadDataPrivacyAgreementDialog({
}
};
const onSubmit = async (data: z.infer<typeof schema>) => {
const onSubmit = (data: z.infer<typeof schema>) => {
if (!uploadedFile) {
return;
}
@@ -108,7 +111,7 @@ export function UploadDataPrivacyAgreementDialog({
return `${dateString}T00:00:00Z`;
};
await mutate({
uploadAgreement({
variables: {
input: {
thirdPartyId,
@@ -121,12 +124,39 @@ export function UploadDataPrivacyAgreementDialog({
uploadables: {
"input.file": uploadedFile,
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to upload Data Privacy Agreement"),
errors,
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Data Privacy Agreement uploaded successfully"),
variant: "success",
});
reset();
setUploadedFile(null);
onSuccess?.();
ref.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to upload Data Privacy Agreement"),
error,
),
variant: "error",
});
},
});
reset();
setUploadedFile(null);
onSuccess?.();
ref.current?.close();
};
const handleClose = () => {
@@ -146,7 +176,7 @@ export function UploadDataPrivacyAgreementDialog({
<DialogContent padded className="space-y-4">
<Dropzone
description={__("Only PDF files up to 10MB are allowed")}
isUploading={isSubmitting}
isUploading={isUploading}
onDrop={handleDrop}
accept={{
"application/pdf": [".pdf"],
@@ -186,8 +216,8 @@ export function UploadDataPrivacyAgreementDialog({
<DialogFooter>
<Button
type="submit"
disabled={isSubmitting || !uploadedFile}
icon={isSubmitting ? Spinner : undefined}
disabled={isUploading || !uploadedFile}
icon={isUploading ? Spinner : undefined}
>
{__("Upload")}
</Button>

View File

@@ -44,8 +44,8 @@ const vetMutation = graphql`
websiteUrl
vettingStatus
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyRiskAssessmentTabFragment
...ThirdPartyCompliancePageFragment
...ThirdPartyRiskAssessmentPageFragment
}
}
}

View File

@@ -17,6 +17,7 @@ import {
certifications,
objectEntries,
} from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Badge,
@@ -30,21 +31,42 @@ import {
import { clsx } from "clsx";
import { useState } from "react";
import { Controller } from "react-hook-form";
import { useOutletContext } from "react-router";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyCertificationsPageQuery } from "#/__generated__/core/ThirdPartyCertificationsPageQuery.graphql";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
/**
* ThirdParty certifications tab
*/
export default function ThirdPartyCertificationsTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
export const thirdPartyCertificationsPageQuery = graphql`
query ThirdPartyCertificationsPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
name
canUpdate: permission(action: "core:thirdParty:update")
...useThirdPartyFormFragment
}
}
}
`;
interface ThirdPartyCertificationsPageProps {
queryRef: PreloadedQuery<ThirdPartyCertificationsPageQuery>;
}
export default function ThirdPartyCertificationsPage(
props: ThirdPartyCertificationsPageProps,
) {
const data = usePreloadedQuery<ThirdPartyCertificationsPageQuery>(thirdPartyCertificationsPageQuery, props.queryRef);
if (data.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const thirdParty = data.node;
const { __ } = useTranslate();
const { control, handleSubmit } = useThirdPartyForm(thirdParty);
usePageTitle(thirdParty.name + " - " + __("Certifications"));
return (
<form
className="space-y-4"
@@ -74,15 +96,12 @@ export default function ThirdPartyCertificationsTab() {
);
}
type CertificationsProps = {
interface CertificationsProps {
value: string[];
onValueChange: (value: string[]) => void;
readOnly?: boolean;
};
}
/**
* List all certifications badges
*/
function Certifications(props: CertificationsProps) {
const categorizedCertifications = Object.values(certifications).flat();
const { __ } = useTranslate();
@@ -92,7 +111,7 @@ function Certifications(props: CertificationsProps) {
([key, value]) =>
[key, value.filter(c => props.value.includes(c))] as const,
)
.filter(([, certifications]) => certifications.length > 0);
.filter(([, certs]) => certs.length > 0);
categories.push([
"custom",
props.value.filter(c => !categorizedCertifications.includes(c)),
@@ -110,13 +129,13 @@ function Certifications(props: CertificationsProps) {
return (
<div className="space-y-6">
{categories.map(([key, certifications]) => (
{categories.map(([key, certs]) => (
<div key={key} className="space-y-2">
<div className="text-sm font-medium text-txt-secondary">
{certificationCategoryLabel(__, key)}
</div>
<div className="flex flex-wrap gap-2">
{certifications.map(certification => (
{certs.map(certification => (
<Badge asChild size="md" key={certification}>
{props.readOnly
? (
@@ -155,9 +174,6 @@ function Certifications(props: CertificationsProps) {
);
}
/**
* Input to add a new certification
*/
function CertificationInput({
certifications,
onAdd,

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyCertificationsPageQuery } from "#/__generated__/core/ThirdPartyCertificationsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyCertificationsPage, { thirdPartyCertificationsPageQuery } from "./ThirdPartyCertificationsPage";
export default function ThirdPartyCertificationsPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyCertificationsPageQuery>(thirdPartyCertificationsPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyCertificationsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
IconPlusLarge,
PageHeader,
Tbody,
Th,
Thead,
Tr,
} from "@probo/ui";
import type { ComponentProps } from "react";
import { graphql, type PreloadedQuery, usePreloadedQuery, useRefetchableFragment } from "react-relay";
import type { ThirdPartyCompliancePageFragment$key } from "#/__generated__/core/ThirdPartyCompliancePageFragment.graphql";
import type { ThirdPartyCompliancePageQuery } from "#/__generated__/core/ThirdPartyCompliancePageQuery.graphql";
import type { ThirdPartyCompliancePageRefetchQuery } from "#/__generated__/core/ThirdPartyCompliancePageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { UploadComplianceReportDialog } from "../_components/UploadComplianceReportDialog";
import { ThirdPartyComplianceReportRow } from "./_components/ThirdPartyComplianceReportRow";
const complianceReportsFragment = graphql`
fragment ThirdPartyCompliancePageFragment on ThirdParty
@refetchable(queryName: "ThirdPartyCompliancePageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyComplianceReportOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
id
name
canUploadComplianceReport: permission(
action: "core:thirdParty-compliance-report:upload"
)
complianceReports(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyCompliancePage_complianceReports") {
__id
edges {
node {
id
...ThirdPartyComplianceReportRow_report
}
}
}
}
`;
export const thirdPartyCompliancePageQuery = graphql`
query ThirdPartyCompliancePageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
...ThirdPartyCompliancePageFragment
}
}
}
`;
interface ThirdPartyCompliancePageProps {
queryRef: PreloadedQuery<ThirdPartyCompliancePageQuery>;
}
export default function ThirdPartyCompliancePage(props: ThirdPartyCompliancePageProps) {
const queryData = usePreloadedQuery<ThirdPartyCompliancePageQuery>(thirdPartyCompliancePageQuery, props.queryRef);
if (queryData.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const [data, refetch] = useRefetchableFragment<
ThirdPartyCompliancePageRefetchQuery,
ThirdPartyCompliancePageFragment$key
>(complianceReportsFragment, queryData.node);
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map(edge => edge.node);
const { __ } = useTranslate();
usePageTitle(data.name + " - " + __("Compliance reports"));
return (
<div className="space-y-6">
<PageHeader
title={__("Compliance reports")}
description={__("Track third party compliance certifications and reports.")}
>
{data.canUploadComplianceReport && (
<UploadComplianceReportDialog
thirdPartyId={data.id}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add report")}</Button>
</UploadComplianceReportDialog>
)}
</PageHeader>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<Th>{__("Report name")}</Th>
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
<Th>{__("Valid until")}</Th>
<Th>{__("File size")}</Th>
{reports.length > 0 && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{reports.map(report => (
<ThirdPartyComplianceReportRow
key={report.id}
reportKey={report}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyCompliancePageQuery } from "#/__generated__/core/ThirdPartyCompliancePageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyCompliancePage, { thirdPartyCompliancePageQuery } from "./ThirdPartyCompliancePage";
export default function ThirdPartyCompliancePageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyCompliancePageQuery>(thirdPartyCompliancePageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyCompliancePage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { downloadFile, fileSize, formatDate, formatError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
DropdownItem,
IconArrowDown,
IconTrashCan,
Td,
Tr,
useConfirm,
useToast,
} from "@probo/ui";
import { graphql, useFragment, useMutation } from "react-relay";
import type { ThirdPartyComplianceReportRow_report$key } from "#/__generated__/core/ThirdPartyComplianceReportRow_report.graphql";
import type { ThirdPartyComplianceReportRowDeleteMutation } from "#/__generated__/core/ThirdPartyComplianceReportRowDeleteMutation.graphql";
const complianceReportRowFragment = graphql`
fragment ThirdPartyComplianceReportRow_report on ThirdPartyComplianceReport {
id
reportDate
validUntil
reportName
file {
fileName
size
downloadUrl
}
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
}
`;
const deleteReportMutation = graphql`
mutation ThirdPartyComplianceReportRowDeleteMutation(
$input: DeleteThirdPartyComplianceReportInput!
$connections: [ID!]!
) {
deleteThirdPartyComplianceReport(input: $input) {
deletedThirdPartyComplianceReportId @deleteEdge(connections: $connections)
}
}
`;
interface ThirdPartyComplianceReportRowProps {
reportKey: ThirdPartyComplianceReportRow_report$key;
connectionId: string;
}
export function ThirdPartyComplianceReportRow(
props: ThirdPartyComplianceReportRowProps,
) {
const { __ } = useTranslate();
const report = useFragment(complianceReportRowFragment, props.reportKey);
const confirm = useConfirm();
const { toast } = useToast();
const [deleteReport] = useMutation<ThirdPartyComplianceReportRowDeleteMutation>(
deleteReportMutation,
);
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
void deleteReport({
variables: {
connections: [props.connectionId],
input: { reportId: report.id },
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete report"),
errors,
),
variant: "error",
});
}
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete report"),
error,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the report \"%s\". This action cannot be undone.",
),
report.reportName,
),
},
);
};
return (
<Tr>
<Td>{report.reportName}</Td>
<Td>{formatDate(report.reportDate)}</Td>
<Td>{formatDate(report.validUntil)}</Td>
<Td>{fileSize(__, report.file?.size ?? 0)}</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

@@ -0,0 +1,170 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
IconPlusLarge,
PageHeader,
Tbody,
Th,
Thead,
Tr,
} from "@probo/ui";
import { useState } from "react";
import { graphql, type PreloadedQuery, usePaginationFragment, usePreloadedQuery } from "react-relay";
import type { ThirdPartyContactsPageFragment$key } from "#/__generated__/core/ThirdPartyContactsPageFragment.graphql";
import type { ThirdPartyContactsPageQuery } from "#/__generated__/core/ThirdPartyContactsPageQuery.graphql";
import type { ThirdPartyContactsPageRefetchQuery } from "#/__generated__/core/ThirdPartyContactsPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { CreateContactDialog } from "../_components/CreateContactDialog";
import { EditContactDialog } from "../_components/EditContactDialog";
import { ThirdPartyContactRow } from "./_components/ThirdPartyContactRow";
const thirdPartyContactsFragment = graphql`
fragment ThirdPartyContactsPageFragment on ThirdParty
@refetchable(queryName: "ThirdPartyContactsPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyContactOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
id
name
canCreateContact: permission(action: "core:thirdParty-contact:create")
contacts(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyContactsPage_contacts") {
__id
edges {
node {
id
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactRow_contact
...EditContactDialog_contact
}
}
}
}
`;
export const thirdPartyContactsPageQuery = graphql`
query ThirdPartyContactsPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
...ThirdPartyContactsPageFragment
}
}
}
`;
interface ThirdPartyContactsPageProps {
queryRef: PreloadedQuery<ThirdPartyContactsPageQuery>;
}
export default function ThirdPartyContactsPage(props: ThirdPartyContactsPageProps) {
const { __ } = useTranslate();
const queryData = usePreloadedQuery<ThirdPartyContactsPageQuery>(thirdPartyContactsPageQuery, props.queryRef);
if (queryData.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const { data, ...pagination } = usePaginationFragment<
ThirdPartyContactsPageRefetchQuery,
ThirdPartyContactsPageFragment$key
>(thirdPartyContactsFragment, queryData.node);
const refetch = ({
order,
}: {
order: { direction: string; field: string };
}) => {
pagination.refetch(
{
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "FULL_NAME" | "EMAIL" | "CREATED_AT",
},
},
{ fetchPolicy: "network-only" },
);
};
const connectionId = data.contacts.__id;
const contacts = data.contacts.edges.map(edge => edge.node);
const [editingContact, setEditingContact]
= useState<(typeof contacts)[number] | null>(null);
const hasAnyAction = contacts.some(
contact => contact.canUpdate || contact.canDelete,
);
usePageTitle(sprintf(__("%s - Contacts"), data.name));
return (
<div className="space-y-6">
<PageHeader
title={__("Contacts")}
description={__("Manage third party contacts and their information.")}
>
{data.canCreateContact && (
<CreateContactDialog thirdPartyId={data.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
)}
</PageHeader>
<SortableTable {...pagination} refetch={refetch}>
<Thead>
<Tr>
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
<Th>{__("Phone")}</Th>
<Th>{__("Role")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{contacts.map(contact => (
<ThirdPartyContactRow
key={contact.id}
contactKey={contact}
connectionId={connectionId}
onEdit={() => setEditingContact(contact)}
/>
))}
</Tbody>
</SortableTable>
{editingContact && editingContact.canUpdate && (
<EditContactDialog
contactKey={editingContact}
onClose={() => setEditingContact(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyContactsPageQuery } from "#/__generated__/core/ThirdPartyContactsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyContactsPage, { thirdPartyContactsPageQuery } from "./ThirdPartyContactsPage";
export default function ThirdPartyContactsPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyContactsPageQuery>(thirdPartyContactsPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyContactsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
DropdownItem,
IconPencil,
IconTrashCan,
Td,
Tr,
useConfirm,
useToast,
} from "@probo/ui";
import { graphql, useFragment, useMutation } from "react-relay";
import type { ThirdPartyContactRow_contact$key } from "#/__generated__/core/ThirdPartyContactRow_contact.graphql";
import type { ThirdPartyContactRowDeleteMutation } from "#/__generated__/core/ThirdPartyContactRowDeleteMutation.graphql";
const contactRowFragment = graphql`
fragment ThirdPartyContactRow_contact on ThirdPartyContact {
id
fullName
email
phone
role
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
}
`;
const deleteContactMutation = graphql`
mutation ThirdPartyContactRowDeleteMutation(
$input: DeleteThirdPartyContactInput!
$connections: [ID!]!
) {
deleteThirdPartyContact(input: $input) {
deletedThirdPartyContactId @deleteEdge(connections: $connections)
}
}
`;
interface ThirdPartyContactRowProps {
contactKey: ThirdPartyContactRow_contact$key;
connectionId: string;
onEdit: () => void;
}
export function ThirdPartyContactRow(props: ThirdPartyContactRowProps) {
const { __ } = useTranslate();
const contact = useFragment(contactRowFragment, props.contactKey);
const confirm = useConfirm();
const { toast } = useToast();
const [deleteContact] = useMutation<ThirdPartyContactRowDeleteMutation>(
deleteContactMutation,
);
const hasAnyAction = contact.canUpdate || contact.canDelete;
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
void deleteContact({
variables: {
connections: [props.connectionId],
input: { thirdPartyContactId: contact.id },
},
onCompleted() {
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete contact"),
error,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the contact \"%s\". This action cannot be undone.",
),
contact.fullName || contact.email || __("Unnamed contact"),
),
},
);
};
return (
<Tr>
<Td>{contact.fullName || __("—")}</Td>
<Td>
{contact.email
? (
<a
href={`mailto:${contact.email}`}
className="text-primary-600 hover:text-primary-800"
>
{contact.email}
</a>
)
: (
__("—")
)}
</Td>
<Td>
{contact.phone
? (
<a
href={`tel:${contact.phone}`}
className="text-primary-600 hover:text-primary-800"
>
{contact.phone}
</a>
)
: (
__("—")
)}
</Td>
<Td>{contact.role || __("—")}</Td>
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{contact.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit()}
>
{__("Edit")}
</DropdownItem>
)}
{contact.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -12,28 +12,32 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { graphql, useFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { ThirdPartyMeasuresPageFragment$key } from "#/__generated__/core/ThirdPartyMeasuresPageFragment.graphql";
import type { ThirdPartyMeasuresPageQuery } from "#/__generated__/core/ThirdPartyMeasuresPageQuery.graphql";
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
export const measuresFragment = graphql`
fragment ThirdPartyMeasuresPageFragment on ThirdParty {
id
canCreateMeasureThirdPartyMapping: permission(
action: "core:measure:create-third-party-mapping"
)
canDeleteMeasureThirdPartyMapping: permission(
action: "core:measure:delete-third-party-mapping"
)
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
__id
edges {
node {
id
...LinkedMeasuresCardFragment
export const thirdPartyMeasuresPageQuery = graphql`
query ThirdPartyMeasuresPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
id
canCreateMeasureThirdPartyMapping: permission(
action: "core:measure:create-third-party-mapping"
)
canDeleteMeasureThirdPartyMapping: permission(
action: "core:measure:delete-third-party-mapping"
)
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
__id
edges {
node {
id
...LinkedMeasuresCardFragment
}
}
}
}
}
@@ -67,24 +71,26 @@ const detachMeasureMutation = graphql`
}
`;
export default function ThirdPartyMeasuresPage() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
if (!thirdPartyId) {
throw new Error("Missing :thirdPartyId param in route");
}
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyMeasuresPageFragment$key;
}>();
const data = useFragment(measuresFragment, thirdParty);
const connectionId = data.measures.__id;
const measures = data.measures?.edges?.map(edge => edge.node) ?? [];
interface ThirdPartyMeasuresPageProps {
queryRef: PreloadedQuery<ThirdPartyMeasuresPageQuery>;
}
const canLink = data.canCreateMeasureThirdPartyMapping;
const canUnlink = data.canDeleteMeasureThirdPartyMapping;
export default function ThirdPartyMeasuresPage(props: ThirdPartyMeasuresPageProps) {
const data = usePreloadedQuery<ThirdPartyMeasuresPageQuery>(thirdPartyMeasuresPageQuery, props.queryRef);
if (data.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const thirdParty = data.node;
const connectionId = thirdParty.measures.__id;
const measures = thirdParty.measures.edges.map(edge => edge.node);
const canLink = thirdParty.canCreateMeasureThirdPartyMapping;
const canUnlink = thirdParty.canDeleteMeasureThirdPartyMapping;
const readOnly = !canLink && !canUnlink;
const incrementOptions = {
id: data.id,
id: thirdParty.id,
node: "measures(first:0)",
};
const [detachMeasure, isDetaching] = useMutationWithIncrement(
@@ -109,7 +115,7 @@ export default function ThirdPartyMeasuresPage() {
measures={measures}
onAttach={attachMeasure}
onDetach={detachMeasure}
params={{ thirdPartyId: data.id }}
params={{ thirdPartyId: thirdParty.id }}
connectionId={connectionId}
readOnly={readOnly}
/>

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyMeasuresPageQuery } from "#/__generated__/core/ThirdPartyMeasuresPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyMeasuresPage, { thirdPartyMeasuresPageQuery } from "./ThirdPartyMeasuresPage";
export default function ThirdPartyMeasuresPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyMeasuresPageQuery>(thirdPartyMeasuresPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyMeasuresPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -26,12 +26,11 @@ import {
Option,
} from "@probo/ui";
import { useMemo } from "react";
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql, type PreloadedQuery, useFragment, usePreloadedQuery } from "react-relay";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabBusinessAssociateAgreementFragment.graphql";
import type { ThirdPartyOverviewTabDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabDataPrivacyAgreementFragment.graphql";
import type { ThirdPartyOverviewPageBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewPageBusinessAssociateAgreementFragment.graphql";
import type { ThirdPartyOverviewPageDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewPageDataPrivacyAgreementFragment.graphql";
import type { ThirdPartyOverviewPageQuery } from "#/__generated__/core/ThirdPartyOverviewPageQuery.graphql";
import type { ThirdPartyCategory } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { CountriesField } from "#/components/form/CountriesField";
@@ -39,15 +38,15 @@ import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeleteBusinessAssociateAgreementDialog } from "../dialogs/DeleteBusinessAssociateAgreementDialog";
import { DeleteDataPrivacyAgreementDialog } from "../dialogs/DeleteDataPrivacyAgreementDialog";
import { EditBusinessAssociateAgreementDialog } from "../dialogs/EditBusinessAssociateAgreementDialog";
import { EditDataPrivacyAgreementDialog } from "../dialogs/EditDataPrivacyAgreementDialog";
import { UploadBusinessAssociateAgreementDialog } from "../dialogs/UploadBusinessAssociateAgreementDialog";
import { UploadDataPrivacyAgreementDialog } from "../dialogs/UploadDataPrivacyAgreementDialog";
import { DeleteBusinessAssociateAgreementDialog } from "../_components/DeleteBusinessAssociateAgreementDialog";
import { DeleteDataPrivacyAgreementDialog } from "../_components/DeleteDataPrivacyAgreementDialog";
import { EditBusinessAssociateAgreementDialog } from "../_components/EditBusinessAssociateAgreementDialog";
import { EditDataPrivacyAgreementDialog } from "../_components/EditDataPrivacyAgreementDialog";
import { UploadBusinessAssociateAgreementDialog } from "../_components/UploadBusinessAssociateAgreementDialog";
import { UploadDataPrivacyAgreementDialog } from "../_components/UploadDataPrivacyAgreementDialog";
const thirdPartyBusinessAssociateAgreementFragment = graphql`
fragment ThirdPartyOverviewTabBusinessAssociateAgreementFragment on ThirdParty {
fragment ThirdPartyOverviewPageBusinessAssociateAgreementFragment on ThirdParty {
businessAssociateAgreement {
id
file {
@@ -67,7 +66,7 @@ const thirdPartyBusinessAssociateAgreementFragment = graphql`
`;
const thirdPartyDataPrivacyAgreementFragment = graphql`
fragment ThirdPartyOverviewTabDataPrivacyAgreementFragment on ThirdParty {
fragment ThirdPartyOverviewPageDataPrivacyAgreementFragment on ThirdParty {
dataPrivacyAgreement {
id
file {
@@ -82,10 +81,38 @@ const thirdPartyDataPrivacyAgreementFragment = graphql`
}
`;
export default function ThirdPartyOverviewTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
export const thirdPartyOverviewPageQuery = graphql`
query ThirdPartyOverviewPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
id
name
canUpdate: permission(action: "core:thirdParty:update")
canUploadBAA: permission(
action: "core:thirdParty-business-associate-agreement:upload"
)
canUploadDPA: permission(
action: "core:thirdParty-data-privacy-agreement:upload"
)
...useThirdPartyFormFragment
...ThirdPartyOverviewPageBusinessAssociateAgreementFragment
...ThirdPartyOverviewPageDataPrivacyAgreementFragment
}
}
}
`;
interface ThirdPartyOverviewPageProps {
queryRef: PreloadedQuery<ThirdPartyOverviewPageQuery>;
}
export default function ThirdPartyOverviewPage(props: ThirdPartyOverviewPageProps) {
const data = usePreloadedQuery<ThirdPartyOverviewPageQuery>(thirdPartyOverviewPageQuery, props.queryRef);
if (data.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const thirdParty = data.node;
const { __ } = useTranslate();
const thirdPartyCategories: { value: ThirdPartyCategory; label: string }[] = [
@@ -125,14 +152,14 @@ export default function ThirdPartyOverviewTab() {
} = useThirdPartyForm(thirdParty);
const thirdPartyWithBAA
= useFragment<ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key>(
= useFragment<ThirdPartyOverviewPageBusinessAssociateAgreementFragment$key>(
thirdPartyBusinessAssociateAgreementFragment,
thirdParty,
);
const businessAssociateAgreement = thirdPartyWithBAA.businessAssociateAgreement;
const thirdPartyWithDPA
= useFragment<ThirdPartyOverviewTabDataPrivacyAgreementFragment$key>(
= useFragment<ThirdPartyOverviewPageDataPrivacyAgreementFragment$key>(
thirdPartyDataPrivacyAgreementFragment,
thirdParty,
);
@@ -169,7 +196,6 @@ export default function ThirdPartyOverviewTab() {
: e => void handleSubmit(e)}
className="space-y-12"
>
{/* ThirdParty Details */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Third party details")}</h2>
<Card className="space-y-4" padded>
@@ -237,7 +263,6 @@ export default function ThirdPartyOverviewTab() {
</Card>
</div>
{/* Ownership */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Ownership details")}</h2>
<Card className="space-y-4" padded>
@@ -262,7 +287,6 @@ export default function ThirdPartyOverviewTab() {
</Card>
</div>
{/* Links */}
<div className="space-y-4 mb-4">
<h2 className="text-base font-medium">{__("Links")}</h2>
<Card className="divide-y divide-border-low">
@@ -292,7 +316,6 @@ export default function ThirdPartyOverviewTab() {
</Card>
</div>
{/* Data agreements */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Data agreements")}</h2>
<Card className="space-y-4" padded>
@@ -446,7 +469,6 @@ export default function ThirdPartyOverviewTab() {
</Card>
</div>
{/* Submit */}
<div className="flex justify-end">
{thirdParty.canUpdate && (
<Button type="submit" disabled={isSubmitting}>

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyOverviewPageQuery } from "#/__generated__/core/ThirdPartyOverviewPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyOverviewPage, { thirdPartyOverviewPageQuery } from "./ThirdPartyOverviewPage";
export default function ThirdPartyOverviewPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyOverviewPageQuery>(thirdPartyOverviewPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyOverviewPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
IconPlusLarge,
Tbody,
Th,
Thead,
Tr,
TrButton,
} from "@probo/ui";
import type { ComponentProps } from "react";
import { useState } from "react";
import { graphql, type PreloadedQuery, usePreloadedQuery, useRefetchableFragment } from "react-relay";
import type { ThirdPartyRiskAssessmentPageFragment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentPageFragment.graphql";
import type { ThirdPartyRiskAssessmentPageQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentPageQuery.graphql";
import type { ThirdPartyRiskAssessmentPageRefetchQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { CreateRiskAssessmentDialog } from "../_components/CreateRiskAssessmentDialog";
import { ThirdPartyRiskAssessmentRow } from "./_components/ThirdPartyRiskAssessmentRow";
const riskAssessmentsFragment = graphql`
fragment ThirdPartyRiskAssessmentPageFragment on ThirdParty
@refetchable(queryName: "ThirdPartyRiskAssessmentPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyRiskAssessmentOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
id
name
canCreateRiskAssessment: permission(
action: "core:thirdParty-risk-assessment:create"
)
riskAssessments(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyRiskAssessmentPage_riskAssessments") {
__id
edges {
node {
id
...ThirdPartyRiskAssessmentRow_assessment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
export const thirdPartyRiskAssessmentPageQuery = graphql`
query ThirdPartyRiskAssessmentPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
...ThirdPartyRiskAssessmentPageFragment
}
}
}
`;
interface ThirdPartyRiskAssessmentPageProps {
queryRef: PreloadedQuery<ThirdPartyRiskAssessmentPageQuery>;
}
export default function ThirdPartyRiskAssessmentPage(
props: ThirdPartyRiskAssessmentPageProps,
) {
const queryData = usePreloadedQuery<ThirdPartyRiskAssessmentPageQuery>(
thirdPartyRiskAssessmentPageQuery,
props.queryRef,
);
if (queryData.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const [data, refetch] = useRefetchableFragment<
ThirdPartyRiskAssessmentPageRefetchQuery,
ThirdPartyRiskAssessmentPageFragment$key
>(riskAssessmentsFragment, queryData.node);
const assessments = data.riskAssessments.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [expanded, setExpanded] = useState<string | null>(null);
usePageTitle(data.name + " - " + __("Risk Assessments"));
if (assessments.length === 0) {
return (
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
{__("No risk assessments found")}
{data.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
thirdPartyId={data.id}
connection={data.riskAssessments.__id}
>
<Button icon={IconPlusLarge} variant="secondary">
{__("Add Risk Assessment")}
</Button>
</CreateRiskAssessmentDialog>
)}
</div>
);
}
return (
<div className="space-y-6 relative">
<div className="overflow-x-auto">
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="CREATED_AT">{__("Created At")}</SortableTh>
<SortableTh field="EXPIRES_AT">{__("Expires")}</SortableTh>
<Th>{__("Data sensitivity")}</Th>
<Th>{__("Business impact")}</Th>
</Tr>
</Thead>
<Tbody>
{data.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
thirdPartyId={data.id}
connection={data.riskAssessments.__id}
>
<TrButton colspan={4} onClick={() => {}}>
{__("Add Risk Assessment")}
</TrButton>
</CreateRiskAssessmentDialog>
)}
{assessments.map(assessment => (
<ThirdPartyRiskAssessmentRow
key={assessment.id}
assessmentKey={assessment}
isExpanded={expanded === assessment.id}
onClick={() =>
setExpanded(prev =>
prev === assessment.id ? null : assessment.id,
)}
/>
))}
</Tbody>
</SortableTable>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyRiskAssessmentPageQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyRiskAssessmentPage, { thirdPartyRiskAssessmentPageQuery } from "./ThirdPartyRiskAssessmentPage";
export default function ThirdPartyRiskAssessmentPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyRiskAssessmentPageQuery>(thirdPartyRiskAssessmentPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyRiskAssessmentPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
RiskBadge,
Td,
Tr,
} from "@probo/ui";
import { clsx } from "clsx";
import { graphql, useFragment } from "react-relay";
import type { ThirdPartyRiskAssessmentRow_assessment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentRow_assessment.graphql";
const riskAssessmentRowFragment = graphql`
fragment ThirdPartyRiskAssessmentRow_assessment on ThirdPartyRiskAssessment {
id
createdAt
expiresAt
dataSensitivity
businessImpact
notes
}
`;
interface ThirdPartyRiskAssessmentRowProps {
assessmentKey: ThirdPartyRiskAssessmentRow_assessment$key;
isExpanded: boolean;
onClick: (id: string) => void;
}
export function ThirdPartyRiskAssessmentRow(props: ThirdPartyRiskAssessmentRowProps) {
const { __ } = useTranslate();
const assessment = useFragment(riskAssessmentRowFragment, props.assessmentKey);
const { relativeDateFormat } = useTranslate();
const isExpired = new Date(assessment.expiresAt) < new Date();
return (
<>
<Tr
className={clsx(
isExpired && "opacity-50",
"cursor-pointer",
props.isExpanded && "border-none",
)}
onClick={() => props.onClick(assessment.id)}
>
<Td>
<span className="text-xs text-txt-secondary ml-1">
{formatDate(assessment.createdAt)}
</span>
</Td>
<Td>
<div className="flex items-center gap-2">
{relativeDateFormat(assessment.expiresAt)}
{isExpired && <Badge variant="neutral">{__("Expired")}</Badge>}
</div>
</Td>
<Td>
<RiskBadge level={assessment.dataSensitivity} />
</Td>
<Td>
<RiskBadge level={assessment.businessImpact} />
</Td>
</Tr>
{props.isExpanded && (
<Tr className={clsx("border-none", isExpired && "opacity-50")}>
<Td colSpan={4}>
<div className="space-y-2">
<div>
{__("Notes")}
:
</div>
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
{assessment.notes}
</p>
</div>
</Td>
</Tr>
)}
</>
);
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 } from "@probo/routes";
import { Fragment } from "react";
import { redirect } from "react-router";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
export const thirdPartyRoutes = [
{
path: "third-parties",
Fallback: PageSkeleton,
Component: lazy(() => import("./ThirdPartiesPageLoader")),
},
{
path: "third-parties/:thirdPartyId",
Fallback: PageSkeleton,
Component: lazy(() => import("./ThirdPartyDetailLayoutLoader")),
children: [
{
path: "",
loader: () => {
// eslint-disable-next-line
throw redirect("overview");
},
Component: Fragment,
},
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./overview/ThirdPartyOverviewPageLoader")),
},
{
path: "certifications",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./certifications/ThirdPartyCertificationsPageLoader")),
},
{
path: "compliance",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./compliance/ThirdPartyCompliancePageLoader")),
},
{
path: "risks",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./risks/ThirdPartyRiskAssessmentPageLoader")),
},
{
path: "contacts",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./contacts/ThirdPartyContactsPageLoader")),
},
{
path: "services",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./services/ThirdPartyServicesPageLoader")),
},
{
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./third-parties/ThirdPartyThirdPartiesPageLoader")),
},
{
path: "measures",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("./measures/ThirdPartyMeasuresPageLoader")),
},
],
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
IconPlusLarge,
PageHeader,
Tbody,
Th,
Thead,
Tr,
} from "@probo/ui";
import { useState } from "react";
import { graphql, type PreloadedQuery, usePaginationFragment, usePreloadedQuery } from "react-relay";
import type { ThirdPartyServicesPageFragment$key } from "#/__generated__/core/ThirdPartyServicesPageFragment.graphql";
import type { ThirdPartyServicesPageQuery } from "#/__generated__/core/ThirdPartyServicesPageQuery.graphql";
import type { ThirdPartyServicesPageRefetchQuery } from "#/__generated__/core/ThirdPartyServicesPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { CreateServiceDialog } from "../_components/CreateServiceDialog";
import { EditServiceDialog } from "../_components/EditServiceDialog";
import { ThirdPartyServiceRow } from "./_components/ThirdPartyServiceRow";
const thirdPartyServicesFragment = graphql`
fragment ThirdPartyServicesPageFragment on ThirdParty
@refetchable(queryName: "ThirdPartyServicesPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyServiceOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
id
name
canCreateService: permission(action: "core:thirdParty-service:create")
services(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyServicesPage_services") {
__id
edges {
node {
id
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
...ThirdPartyServiceRow_service
...EditServiceDialog_service
}
}
}
}
`;
export const thirdPartyServicesPageQuery = graphql`
query ThirdPartyServicesPageQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
__typename
... on ThirdParty {
...ThirdPartyServicesPageFragment
}
}
}
`;
interface ThirdPartyServicesPageProps {
queryRef: PreloadedQuery<ThirdPartyServicesPageQuery>;
}
export default function ThirdPartyServicesPage(props: ThirdPartyServicesPageProps) {
const { __ } = useTranslate();
const queryData = usePreloadedQuery<ThirdPartyServicesPageQuery>(thirdPartyServicesPageQuery, props.queryRef);
if (queryData.node?.__typename !== "ThirdParty") {
throw new Error("Third party not found");
}
const { data, ...pagination } = usePaginationFragment<
ThirdPartyServicesPageRefetchQuery,
ThirdPartyServicesPageFragment$key
>(thirdPartyServicesFragment, queryData.node);
const refetch = ({
order,
}: {
order: { direction: string; field: string };
}) => {
pagination.refetch(
{
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "NAME" | "CREATED_AT",
},
},
{ fetchPolicy: "network-only" },
);
};
const connectionId = data.services.__id;
const services = data.services.edges.map(edge => edge.node);
const [editingService, setEditingService]
= useState<(typeof services)[number] | null>(null);
const hasAnyAction = services.some(
service => service.canUpdate || service.canDelete,
);
usePageTitle(data.name + " - " + __("Services"));
return (
<div className="space-y-6">
<PageHeader
title={__("Services")}
description={__("Manage services provided by this third party.")}
>
{data.canCreateService && (
<CreateServiceDialog thirdPartyId={data.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
)}
</PageHeader>
<SortableTable {...pagination} refetch={refetch}>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Description")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{services.map(service => (
<ThirdPartyServiceRow
key={service.id}
serviceKey={service}
connectionId={connectionId}
onEdit={() => setEditingService(service)}
/>
))}
</Tbody>
</SortableTable>
{editingService && editingService.canUpdate && (
<EditServiceDialog
serviceKey={editingService}
onClose={() => setEditingService(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@probo.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 { ThirdPartyServicesPageQuery } from "#/__generated__/core/ThirdPartyServicesPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import ThirdPartyServicesPage, { thirdPartyServicesPageQuery } from "./ThirdPartyServicesPage";
export default function ThirdPartyServicesPageLoader() {
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartyServicesPageQuery>(thirdPartyServicesPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return (
<Suspense fallback={<LinkCardSkeleton />}>
<ThirdPartyServicesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
DropdownItem,
IconPencil,
IconTrashCan,
Td,
Tr,
useConfirm,
useToast,
} from "@probo/ui";
import { graphql, useFragment, useMutation } from "react-relay";
import type { ThirdPartyServiceRow_service$key } from "#/__generated__/core/ThirdPartyServiceRow_service.graphql";
import type { ThirdPartyServiceRowDeleteMutation } from "#/__generated__/core/ThirdPartyServiceRowDeleteMutation.graphql";
const serviceRowFragment = graphql`
fragment ThirdPartyServiceRow_service on ThirdPartyService {
id
name
description
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
}
`;
const deleteServiceMutation = graphql`
mutation ThirdPartyServiceRowDeleteMutation(
$input: DeleteThirdPartyServiceInput!
$connections: [ID!]!
) {
deleteThirdPartyService(input: $input) {
deletedThirdPartyServiceId @deleteEdge(connections: $connections)
}
}
`;
interface ThirdPartyServiceRowProps {
serviceKey: ThirdPartyServiceRow_service$key;
connectionId: string;
onEdit: () => void;
}
export function ThirdPartyServiceRow(props: ThirdPartyServiceRowProps) {
const { __ } = useTranslate();
const service = useFragment(serviceRowFragment, props.serviceKey);
const confirm = useConfirm();
const { toast } = useToast();
const [deleteService] = useMutation<ThirdPartyServiceRowDeleteMutation>(
deleteServiceMutation,
);
const hasAnyAction = service.canUpdate || service.canDelete;
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
void deleteService({
variables: {
connections: [props.connectionId],
input: { thirdPartyServiceId: service.id },
},
onCompleted(_response, errors) {
if (errors) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete service"),
errors,
),
variant: "error",
});
}
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete service"),
error,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the service \"%s\". This action cannot be undone.",
),
service.name,
),
},
);
};
return (
<Tr>
<Td>{service.name}</Td>
<Td>{service.description || __("—")}</Td>
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{service.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit()}
>
{__("Edit")}
</DropdownItem>
)}
{service.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,229 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { downloadFile, fileSize, formatDate, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Button,
DropdownItem,
IconArrowDown,
IconPlusLarge,
IconTrashCan,
PageHeader,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import type { ComponentProps } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyComplianceTabFragment_report$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment_report.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { UploadComplianceReportDialog } from "../dialogs/UploadComplianceReportDialog";
export const complianceReportsFragment = graphql`
fragment ThirdPartyComplianceTabFragment on ThirdParty
@refetchable(queryName: "ComplianceReportListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyComplianceReportOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
complianceReports(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyComplianceTabFragment_complianceReports") {
__id
edges {
node {
id
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
...ThirdPartyComplianceTabFragment_report
}
}
}
}
`;
const complianceReportFragment = graphql`
fragment ThirdPartyComplianceTabFragment_report on ThirdPartyComplianceReport {
id
reportDate
validUntil
reportName
file {
fileName
size
downloadUrl
}
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
}
`;
const deleteReportMutation = graphql`
mutation ThirdPartyComplianceTabDeleteReportMutation(
$input: DeleteThirdPartyComplianceReportInput!
$connections: [ID!]!
) {
deleteThirdPartyComplianceReport(input: $input) {
deletedThirdPartyComplianceReportId @deleteEdge(connections: $connections)
}
}
`;
export default function ThirdPartyComplianceTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
ComplianceReportListQuery,
ThirdPartyComplianceTabFragment$key
>(complianceReportsFragment, thirdParty);
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map(edge => edge.node);
const { __ } = useTranslate();
usePageTitle(thirdParty.name + " - " + __("Compliance reports"));
return (
<div className="space-y-6">
<PageHeader
title={__("Compliance reports")}
description={__("Track third party compliance certifications and reports.")}
>
{thirdParty.canUploadComplianceReport && (
<UploadComplianceReportDialog
thirdPartyId={thirdParty.id}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add report")}</Button>
</UploadComplianceReportDialog>
)}
</PageHeader>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<Th>{__("Report name")}</Th>
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
<Th>{__("Valid until")}</Th>
<Th>{__("File size")}</Th>
{reports.length > 0 && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{reports.map(report => (
<ReportRow
key={report.id}
reportKey={report}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>
</div>
);
}
type ReportRowProps = {
reportKey: ThirdPartyComplianceTabFragment_report$key;
connectionId: string;
};
function ReportRow(props: ReportRowProps) {
const { __ } = useTranslate();
const report = useFragment<ThirdPartyComplianceTabFragment_report$key>(
complianceReportFragment,
props.reportKey,
);
const confirm = useConfirm();
const [deleteReport] = useMutationWithToasts(deleteReportMutation, {
successMessage: __("Report deleted successfully"),
errorMessage: __("Failed to delete report"),
});
const handleDelete = () => {
confirm(
() =>
deleteReport({
variables: {
connections: [props.connectionId],
input: {
reportId: report.id,
},
},
}),
{
message: sprintf(
__(
"This will permanently delete the report \"%s\". This action cannot be undone.",
),
report.reportName,
),
},
);
};
return (
<Tr>
<Td>{report.reportName}</Td>
<Td>{formatDate(report.reportDate)}</Td>
<Td>{formatDate(report.validUntil)}</Td>
<Td>{fileSize(__, report.file?.size ?? 0)}</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

@@ -1,269 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Button,
DropdownItem,
IconPencil,
IconPlusLarge,
IconTrashCan,
PageHeader,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ThirdPartyContactsListQuery } from "#/__generated__/core/ThirdPartyContactsListQuery.graphql";
import type { ThirdPartyContactsTabFragment$key } from "#/__generated__/core/ThirdPartyContactsTabFragment.graphql";
import type {
ThirdPartyContactsTabFragment_contact$data,
ThirdPartyContactsTabFragment_contact$key,
} from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CreateContactDialog } from "../dialogs/CreateContactDialog";
import { EditContactDialog } from "../dialogs/EditContactDialog";
export const thirdPartyContactsFragment = graphql`
fragment ThirdPartyContactsTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyContactsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyContactOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
contacts(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
__id
edges {
node {
id
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactsTabFragment_contact
}
}
}
}
`;
const contactFragment = graphql`
fragment ThirdPartyContactsTabFragment_contact on ThirdPartyContact {
id
fullName
email
phone
role
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
}
`;
const deleteContactMutation = graphql`
mutation ThirdPartyContactsTabDeleteContactMutation(
$input: DeleteThirdPartyContactInput!
$connections: [ID!]!
) {
deleteThirdPartyContact(input: $input) {
deletedThirdPartyContactId @deleteEdge(connections: $connections)
}
}
`;
export default function ThirdPartyContactsTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
ThirdPartyContactsListQuery,
ThirdPartyContactsTabFragment$key
>(thirdPartyContactsFragment, thirdParty);
const connectionId = data.contacts.__id;
const contacts = data.contacts.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [editingContact, setEditingContact]
= useState<ThirdPartyContactsTabFragment_contact$data | null>(null);
const hasAnyAction = contacts.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(thirdParty.name + " - " + __("Contacts"));
return (
<div className="space-y-6">
<PageHeader
title={__("Contacts")}
description={__("Manage third party contacts and their information.")}
>
{thirdParty.canCreateContact && (
<CreateContactDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
)}
</PageHeader>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
<Th>{__("Phone")}</Th>
<Th>{__("Role")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{contacts.map(contact => (
<ContactRow
key={contact.id}
contactKey={contact}
connectionId={connectionId}
onEdit={setEditingContact}
/>
))}
</Tbody>
</SortableTable>
{editingContact && editingContact.canUpdate && (
<EditContactDialog
contactId={editingContact.id}
contact={editingContact}
onClose={() => setEditingContact(null)}
/>
)}
</div>
);
}
type ContactRowProps = {
contactKey: ThirdPartyContactsTabFragment_contact$key;
connectionId: string;
onEdit: (contact: ThirdPartyContactsTabFragment_contact$data) => void;
};
function ContactRow(props: ContactRowProps) {
const { __ } = useTranslate();
const contact = useFragment<ThirdPartyContactsTabFragment_contact$key>(
contactFragment,
props.contactKey,
);
const confirm = useConfirm();
const [deleteContact] = useMutationWithToasts(deleteContactMutation, {
successMessage: __("Contact deleted successfully"),
errorMessage: __("Failed to delete contact"),
});
const hasAnyAction = contact.canUpdate || contact.canDelete;
const handleDelete = () => {
confirm(
() =>
deleteContact({
variables: {
connections: [props.connectionId],
input: {
thirdPartyContactId: contact.id,
},
},
}),
{
message: sprintf(
__(
"This will permanently delete the contact \"%s\". This action cannot be undone.",
),
contact.fullName || contact.email || __("Unnamed contact"),
),
},
);
};
return (
<Tr>
<Td>{contact.fullName || __("—")}</Td>
<Td>
{contact.email
? (
<a
href={`mailto:${contact.email}`}
className="text-primary-600 hover:text-primary-800"
>
{contact.email}
</a>
)
: (
__("—")
)}
</Td>
<Td>
{contact.phone
? (
<a
href={`tel:${contact.phone}`}
className="text-primary-600 hover:text-primary-800"
>
{contact.phone}
</a>
)
: (
__("—")
)}
</Td>
<Td>{contact.role || __("—")}</Td>
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{contact.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit(contact)}
>
{__("Edit")}
</DropdownItem>
)}
{contact.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,226 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
IconPlusLarge,
RiskBadge,
Tbody,
Td,
Th,
Thead,
Tr,
TrButton,
} from "@probo/ui";
import { clsx } from "clsx";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyRiskAssessmentTabFragment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentTabFragment.graphql";
import type { ThirdPartyRiskAssessmentTabFragment_assessment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentTabFragment_assessment.graphql";
import type { ThirdPartyRiskAssessmentTabQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentTabQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { CreateRiskAssessmentDialog } from "../dialogs/CreateRiskAssessmentDialog";
const riskAssessmentsFragment = graphql`
fragment ThirdPartyRiskAssessmentTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyRiskAssessmentTabQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyRiskAssessmentOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
id
riskAssessments(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyRiskAssessmentTabFragment_riskAssessments") {
__id
edges {
node {
id
...ThirdPartyRiskAssessmentTabFragment_assessment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
const riskAssessmentFragment = graphql`
fragment ThirdPartyRiskAssessmentTabFragment_assessment on ThirdPartyRiskAssessment {
id
createdAt
expiresAt
dataSensitivity
businessImpact
notes
}
`;
export default function ThirdPartyRiskAssessmentTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
ThirdPartyRiskAssessmentTabQuery,
ThirdPartyRiskAssessmentTabFragment$key
>(riskAssessmentsFragment, thirdParty);
const assessments = data.riskAssessments.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [expanded, setExpanded] = useState<string | null>(null);
usePageTitle(thirdParty.name + " - " + __("Risk Assessments"));
if (assessments.length === 0) {
return (
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
{__("No risk assessments found")}
{thirdParty.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
thirdPartyId={thirdParty.id}
connection={data.riskAssessments.__id}
>
<Button icon={IconPlusLarge} variant="secondary">
{__("Add Risk Assessment")}
</Button>
</CreateRiskAssessmentDialog>
)}
</div>
);
}
return (
<div className="space-y-6 relative">
<div className="flex justify-end"></div>
<div className="overflow-x-auto">
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="CREATED_AT">{__("Created At")}</SortableTh>
<SortableTh field="EXPIRES_AT">{__("Expires")}</SortableTh>
<Th>{__("Data sensitivity")}</Th>
<Th>{__("Business impact")}</Th>
</Tr>
</Thead>
<Tbody>
{thirdParty.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
thirdPartyId={thirdParty.id}
connection={data.riskAssessments.__id}
>
<TrButton colspan={5} onClick={() => {}}>
{__("Add Risk Assessment")}
</TrButton>
</CreateRiskAssessmentDialog>
)}
{assessments.map(assessment => (
<AssessmentRow
key={assessment.id}
assessmentKey={assessment}
isExpanded={expanded === assessment.id}
onClick={() =>
setExpanded(prev =>
prev === assessment.id ? null : assessment.id,
)}
/>
))}
</Tbody>
</SortableTable>
</div>
</div>
);
}
type AssessmentRowProps = {
assessmentKey: ThirdPartyRiskAssessmentTabFragment_assessment$key;
onClick: (id: string) => void;
isExpanded: boolean;
};
function AssessmentRow(props: AssessmentRowProps) {
const { __ } = useTranslate();
const assessment
= useFragment<ThirdPartyRiskAssessmentTabFragment_assessment$key>(
riskAssessmentFragment,
props.assessmentKey,
);
const { relativeDateFormat } = useTranslate();
const isExpired = new Date(assessment.expiresAt) < new Date();
return (
<>
<Tr
className={clsx(
isExpired && "opacity-50",
"cursor-pointer",
props.isExpanded && "border-none",
)}
onClick={() => props.onClick(assessment.id)}
>
<Td>
<span className="text-xs text-txt-secondary ml-1">
{formatDate(assessment.createdAt)}
</span>
</Td>
<Td>
<div className="flex items-center gap-2">
{relativeDateFormat(assessment.expiresAt)}
{isExpired && <Badge variant="neutral">{__("Expired")}</Badge>}
</div>
</Td>
<Td>
<RiskBadge level={assessment.dataSensitivity} />
</Td>
<Td>
<RiskBadge level={assessment.businessImpact} />
</Td>
</Tr>
{props.isExpanded && (
<Tr className={clsx("border-none", isExpired && "opacity-50")}>
<Td colSpan={4}>
<div className="space-y-2">
<div>
{__("Notes")}
:
</div>
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
{assessment.notes}
</p>
</div>
</Td>
</Tr>
)}
</>
);
}

View File

@@ -1,237 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Button,
DropdownItem,
IconPencil,
IconPlusLarge,
IconTrashCan,
PageHeader,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyServicesListQuery } from "#/__generated__/core/ThirdPartyServicesListQuery.graphql";
import type { ThirdPartyServicesTabFragment$key } from "#/__generated__/core/ThirdPartyServicesTabFragment.graphql";
import type {
ThirdPartyServicesTabFragment_service$data,
ThirdPartyServicesTabFragment_service$key,
} from "#/__generated__/core/ThirdPartyServicesTabFragment_service.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CreateServiceDialog } from "../dialogs/CreateServiceDialog";
import { EditServiceDialog } from "../dialogs/EditServiceDialog";
export const thirdPartyServicesFragment = graphql`
fragment ThirdPartyServicesTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyServicesListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyServiceOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
services(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartyServicesTabFragment_services") {
__id
edges {
node {
id
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
...ThirdPartyServicesTabFragment_service
}
}
}
}
`;
const serviceFragment = graphql`
fragment ThirdPartyServicesTabFragment_service on ThirdPartyService {
id
name
description
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
}
`;
const deleteServiceMutation = graphql`
mutation ThirdPartyServicesTabDeleteServiceMutation(
$input: DeleteThirdPartyServiceInput!
$connections: [ID!]!
) {
deleteThirdPartyService(input: $input) {
deletedThirdPartyServiceId @deleteEdge(connections: $connections)
}
}
`;
export default function ThirdPartyServicesTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
ThirdPartyServicesListQuery,
ThirdPartyServicesTabFragment$key
>(thirdPartyServicesFragment, thirdParty);
const connectionId = data.services.__id;
const services = data.services.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [editingService, setEditingService]
= useState<ThirdPartyServicesTabFragment_service$data | null>(null);
const hasAnyAction = services.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(thirdParty.name + " - " + __("Services"));
return (
<div className="space-y-6">
<PageHeader
title={__("Services")}
description={__("Manage services provided by this third party.")}
>
{thirdParty.canCreateService && (
<CreateServiceDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
)}
</PageHeader>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Description")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{services.map(service => (
<ServiceRow
key={service.id}
serviceKey={service}
connectionId={connectionId}
onEdit={setEditingService}
/>
))}
</Tbody>
</SortableTable>
{editingService && editingService.canUpdate && (
<EditServiceDialog
serviceId={editingService.id}
service={editingService}
onClose={() => setEditingService(null)}
/>
)}
</div>
);
}
type ServiceRowProps = {
serviceKey: ThirdPartyServicesTabFragment_service$key;
connectionId: string;
onEdit: (service: ThirdPartyServicesTabFragment_service$data) => void;
};
function ServiceRow(props: ServiceRowProps) {
const { __ } = useTranslate();
const service = useFragment<ThirdPartyServicesTabFragment_service$key>(
serviceFragment,
props.serviceKey,
);
const confirm = useConfirm();
const [deleteService] = useMutationWithToasts(deleteServiceMutation, {
successMessage: __("Service deleted successfully"),
errorMessage: __("Failed to delete service"),
});
const hasAnyAction = service.canUpdate || service.canDelete;
const handleDelete = () => {
confirm(
() =>
deleteService({
variables: {
connections: [props.connectionId],
input: {
thirdPartyServiceId: service.id,
},
},
}),
{
message: sprintf(
__(
"This will permanently delete the service \"%s\". This action cannot be undone.",
),
service.name,
),
},
);
};
return (
<Tr>
<Td>{service.name}</Td>
<Td>{service.description || __("—")}</Td>
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{service.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit(service)}
>
{__("Edit")}
</DropdownItem>
)}
{service.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -45,7 +45,7 @@ import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/Third
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
import { AddChildThirdPartyDialog } from "../_components/AddChildThirdPartyDialog";
// Keep in sync with coredata.MaxThirdPartyLevel on the backend.
const MAX_THIRD_PARTY_LEVEL = 4;

View File

@@ -33,6 +33,7 @@ import { compliancePageRoutes } from "./pages/organizations/compliance-page/rout
import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
import { riskAssessmentRoutes } from "./pages/organizations/risk-assessments/routes";
import { riskRoutes } from "./pages/organizations/risks/routes";
import { thirdPartyRoutes } from "./pages/organizations/third-parties/routes";
import { CurrentUser } from "./providers/CurrentUser";
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
import { assetRoutes } from "./routes/assetRoutes";
@@ -48,7 +49,6 @@ import { processingActivityRoutes } from "./routes/processingActivityRoutes";
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
import { taskRoutes } from "./routes/taskRoutes";
import { thirdPartyRoutes } from "./routes/thirdPartyRoutes";
const routes = [
{

View File

@@ -1,117 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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 { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { thirdPartiesQuery, thirdPartyNodeQuery } from "#/hooks/graph/ThirdPartyGraph";
export const thirdPartyRoutes = [
{
path: "third-parties",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ThirdPartyGraphListQuery>(coreEnvironment, thirdPartiesQuery, {
organizationId: organizationId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/third-parties/ThirdPartiesPage")),
),
},
{
path: "third-parties/:thirdPartyId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ thirdPartyId }) =>
loadQuery<ThirdPartyGraphNodeQuery>(coreEnvironment, thirdPartyNodeQuery, {
thirdPartyId: thirdPartyId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/third-parties/ThirdPartyDetailPage")),
),
children: [
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/third-parties/tabs/ThirdPartyOverviewTab"),
),
},
{
path: "certifications",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/tabs/ThirdPartyCertificationsTab"),
),
},
{
path: "compliance",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/tabs/ThirdPartyComplianceTab"),
),
},
{
path: "risks",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab"),
),
},
{
path: "contacts",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/third-parties/tabs/ThirdPartyContactsTab"),
),
},
{
path: "services",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
),
},
{
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
),
},
{
path: "measures",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/third-parties/measures/ThirdPartyMeasuresPage"),
),
},
],
},
] satisfies AppRoute[];