Rename vendors to third parties

Renames the user-facing 'vendor' concept to 'third party' across the
entire codebase. The shared common_third_parties reference table is
unchanged.

Migration. Renames the vendor_category enum, the vendors and
vendor_<entity> tables (contacts, services, compliance_reports,
business_associate_agreements, data_privacy_agreements,
risk_assessments) and their vendor_id columns, the asset_vendors /
data_vendors / processing_activity_vendors junction tables,
generated_documents.vendors_document_id, the webhook_event_type
'vendor:<verb>' values, and the snapshots_type 'VENDORS' value.

Backend. Renames coredata models and SQL queries, probo services,
GraphQL / MCP API surface, console / trust / webhook resolvers and
types, the CLI (prb vendor* -> prb third-party*; pkg/cmd/vendormgmt
-> pkg/cmd/thirdpartymgmt), the document generator, vetting agent
prompts, and the common-third-parties-import command.

Frontend, packages, n8n, e2e. Renames apps/console pages, components,
hooks, routes, dialogs, and tabs; the shared @probo/vendors package
(now @probo/third-parties); the @probo/ui Vendors atoms (now
ThirdParties, VendorLogo -> ThirdPartyLogo); the n8n community node
actions/vendor folder (now actions/thirdParty); and the e2e Go test
suite (console and MCP). Filesystem and URL paths use kebab-case
(third-parties), GraphQL fields and TypeScript identifiers use
camelCase (thirdParty / thirdParties), Go types use PascalCase
(ThirdParty), and human-facing text uses 'third party' with a space.

Co-authored-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-13 16:15:33 +02:00
parent 9eed0d71c8
commit eecbe4c46c
281 changed files with 8491 additions and 8425 deletions

View File

@@ -21,7 +21,7 @@
"@probo/relay": "^1.0.0",
"@probo/routes": "^1.0.0",
"@probo/ui": "1.0.0",
"@probo/vendors": "0.0.1",
"@probo/third-parties": "0.0.1",
"@tanstack/react-query": "^5.76.1",
"clsx": "^2.1.1",
"react": "^19.1.0",

View File

@@ -48,7 +48,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import { EditableTable } from "../table/EditableTable";
import { PeopleCell } from "../table/PeopleCell";
import { VendorsCell } from "../table/VendorsCell";
import { ThirdPartiesCell } from "../table/ThirdPartiesCell";
type Props = {
connectionId: string;
@@ -65,7 +65,7 @@ const schema = z.object({
amount: z.coerce.number().min(1, "Amount is required"),
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
ownerId: z.string().trim().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
dataTypesStored: z.string().trim().min(1, "Data types stored is required"),
organizationId: z.string().trim().min(1, "Organization is required"),
});
@@ -75,7 +75,7 @@ const defaultValue = {
amount: 0,
assetType: "VIRTUAL",
ownerId: "",
vendorIds: [],
thirdPartyIds: [],
dataTypesStored: "",
organizationId: "",
} satisfies z.infer<typeof schema>;
@@ -99,7 +99,7 @@ export function AssetsTable(props: Props) {
__("Data Types stored"),
__("Amount"),
__("Owner"),
__("Vendors"),
__("Third parties"),
]}
schema={schema}
updateMutation={updateAssetMutation}
@@ -154,10 +154,10 @@ export function AssetsTable(props: Props) {
defaultValue={item?.owner}
organizationId={organizationId}
/>
<VendorsCell
name="vendorIds"
<ThirdPartiesCell
name="thirdPartyIds"
organizationId={organizationId}
defaultValue={item?.vendors?.edges?.map(edge => edge.node) ?? []}
defaultValue={item?.thirdParties?.edges?.map(edge => edge.node) ?? []}
/>
</>
)}

View File

@@ -50,7 +50,7 @@ export function ReadOnlyAssetsTable(props: Props) {
<Th>{__("Type")}</Th>
<Th>{__("Amount")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
<Th>{__("Third parties")}</Th>
</Tr>
</Thead>
<Tbody>
@@ -65,7 +65,7 @@ export function ReadOnlyAssetsTable(props: Props) {
function AssetRow({ entry }: { entry: AssetEntry }) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
const thirdParties = entry.thirdParties?.edges.map(edge => edge.node) ?? [];
return (
<Tr to={`/organizations/${organizationId}/assets/${entry.id}`}>
@@ -78,27 +78,27 @@ function AssetRow({ entry }: { entry: AssetEntry }) {
<Td>{entry.amount}</Td>
<Td>{entry.owner?.fullName ?? __("Unassigned")}</Td>
<Td>
{vendors.length > 0
{thirdParties.length > 0
? (
<div className="flex flex-wrap gap-1">
{vendors.slice(0, 3).map(vendor => (
{thirdParties.slice(0, 3).map(thirdParty => (
<Badge
key={vendor.id}
key={thirdParty.id}
variant="neutral"
className="flex items-center gap-1"
>
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<span className="text-xs">{vendor.name}</span>
<span className="text-xs">{thirdParty.name}</span>
</Badge>
))}
{vendors.length > 3 && (
{thirdParties.length > 3 && (
<Badge variant="neutral" className="text-xs">
+
{vendors.length - 3}
{thirdParties.length - 3}
</Badge>
)}
</div>

View File

@@ -18,9 +18,9 @@ import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@
import { type ComponentProps, Suspense, useState } from "react";
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
import { useVendors } from "#/hooks/graph/VendorGraph";
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
type Vendor = {
type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
@@ -32,13 +32,13 @@ type Props<T extends FieldValues = FieldValues> = {
name: string;
label?: string;
error?: string;
selectedVendors?: Vendor[];
selectedThirdParties?: ThirdParty[];
} & ComponentProps<typeof Field>;
export function VendorsMultiSelectField<T extends FieldValues = FieldValues>({
export function ThirdPartiesMultiSelectField<T extends FieldValues = FieldValues>({
organizationId,
control,
selectedVendors = [],
selectedThirdParties = [],
...props
}: Props<T>) {
return (
@@ -46,31 +46,31 @@ export function VendorsMultiSelectField<T extends FieldValues = FieldValues>({
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<VendorsMultiSelectWithQuery
<ThirdPartiesMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
selectedVendors={selectedVendors}
selectedThirdParties={selectedThirdParties}
/>
</Suspense>
</Field>
);
}
function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedVendors">,
function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedThirdParties">,
) {
const { __ } = useTranslate();
const { name, organizationId, control, selectedVendors = [] } = props;
const vendors = useVendors(organizationId);
const { name, organizationId, control, selectedThirdParties = [] } = props;
const thirdParties = useThirdParties(organizationId);
const [isOpen, setIsOpen] = useState(false);
const allVendors = [...vendors];
const allThirdParties = [...thirdParties];
if (props.disabled) {
selectedVendors.forEach((selectedVendor) => {
if (!allVendors.find(v => v.id === selectedVendor.id)) {
allVendors.push(selectedVendor);
selectedThirdParties.forEach((selectedThirdParty) => {
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {
allThirdParties.push(selectedThirdParty);
}
});
}
@@ -81,49 +81,49 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
control={control}
name={name as Path<T>}
render={({ field }) => {
const selectedVendorIds = (Array.isArray(field.value) ? field.value : []) as string[];
const selectedThirdPartyIds = (Array.isArray(field.value) ? field.value : []) as string[];
const selectedVendors = allVendors.filter(v => selectedVendorIds.includes(v.id));
const availableVendors = allVendors.filter(v => !selectedVendorIds.includes(v.id));
const selectedThirdParties = allThirdParties.filter(v => selectedThirdPartyIds.includes(v.id));
const availableThirdParties = allThirdParties.filter(v => !selectedThirdPartyIds.includes(v.id));
const handleAddVendor = (vendorId: string) => {
const newValue = [...selectedVendorIds, vendorId];
const handleAddThirdParty = (thirdPartyId: string) => {
const newValue = [...selectedThirdPartyIds, thirdPartyId];
field.onChange(newValue);
setIsOpen(false);
};
const handleRemoveVendor = (vendorId: string) => {
const newValue = selectedVendorIds.filter((id: string) => id !== vendorId);
const handleRemoveThirdParty = (thirdPartyId: string) => {
const newValue = selectedThirdPartyIds.filter((id: string) => id !== thirdPartyId);
field.onChange(newValue);
};
return (
<div className="space-y-2">
{availableVendors.length > 0 && !props.disabled && (
{availableThirdParties.length > 0 && !props.disabled && (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Add vendors...")}
onValueChange={handleAddVendor}
key={`${selectedVendorIds.length}-${vendors.length}`}
placeholder={__("Add third parties...")}
onValueChange={handleAddThirdParty}
key={`${selectedThirdPartyIds.length}-${thirdParties.length}`}
className="w-full"
value=""
open={isOpen}
onOpenChange={setIsOpen}
>
{availableVendors.map(vendor => (
<Option key={vendor.id} value={vendor.id} className="flex gap-2">
{availableThirdParties.map(thirdParty => (
<Option key={thirdParty.id} value={thirdParty.id} className="flex gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<div className="flex flex-col">
<span>{vendor.name}</span>
{vendor.websiteUrl && (
<span>{thirdParty.name}</span>
{thirdParty.websiteUrl && (
<span className="text-xs text-txt-secondary">
{vendor.websiteUrl}
{thirdParty.websiteUrl}
</span>
)}
</div>
@@ -132,21 +132,21 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
</Select>
)}
{selectedVendors.length > 0 && (
{selectedThirdParties.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedVendors.map(vendor => (
<Badge key={vendor.id} variant="neutral" className="flex items-center gap-2">
{selectedThirdParties.map(thirdParty => (
<Badge key={thirdParty.id} variant="neutral" className="flex items-center gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<span>{vendor.name}</span>
<span>{thirdParty.name}</span>
{!props.disabled && (
<Button
variant="tertiary"
icon={IconCrossLargeX}
onClick={() => handleRemoveVendor(vendor.id)}
onClick={() => handleRemoveThirdParty(thirdParty.id)}
className="h-4 w-4 p-0 hover:bg-transparent"
/>
)}
@@ -155,9 +155,9 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
</div>
)}
{selectedVendors.length === 0 && availableVendors.length === 0 && (
{selectedThirdParties.length === 0 && availableThirdParties.length === 0 && (
<div className="text-sm text-txt-secondary py-2">
{__("No vendors available")}
{__("No third parties available")}
</div>
)}
</div>

View File

@@ -15,11 +15,11 @@
import { faviconUrl } from "@probo/helpers";
import { Avatar, Badge, IconCrossLargeX } from "@probo/ui";
import type { VendorGraphSelectQuery } from "#/__generated__/core/VendorGraphSelectQuery.graphql";
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
import { GraphQLCell } from "#/components/table/GraphQLCell";
import { vendorsSelectQuery } from "#/hooks/graph/VendorGraph";
import { thirdPartiesSelectQuery } from "#/hooks/graph/ThirdPartyGraph";
type Vendor = {
type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
@@ -27,47 +27,47 @@ type Vendor = {
type Props = {
name: string;
defaultValue?: Vendor[];
defaultValue?: ThirdParty[];
organizationId: string;
};
const empty = [] as Vendor[];
const empty = [] as ThirdParty[];
export function VendorsCell(props: Props) {
export function ThirdPartiesCell(props: Props) {
return (
<GraphQLCell<VendorGraphSelectQuery, Vendor>
<GraphQLCell<ThirdPartyGraphSelectQuery, ThirdParty>
multiple
name={props.name}
query={vendorsSelectQuery}
query={thirdPartiesSelectQuery}
variables={{
organizationId: props.organizationId,
}}
items={data =>
data.organization?.vendors?.edges?.map(edge => edge.node) ?? []}
data.organization?.thirdParties?.edges?.map(edge => edge.node) ?? []}
itemRenderer={({ item, onRemove }) => (
<VendorBadge vendor={item} onRemove={onRemove} />
<ThirdPartyBadge thirdParty={item} onRemove={onRemove} />
)}
defaultValue={props.defaultValue ?? empty}
/>
);
}
function VendorBadge({
vendor,
function ThirdPartyBadge({
thirdParty,
onRemove,
}: {
vendor: Vendor;
onRemove?: (v: Vendor) => void;
thirdParty: ThirdParty;
onRemove?: (v: ThirdParty) => void;
}) {
return (
<Badge variant="neutral" className="flex items-center gap-1">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} size="s" />
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} size="s" />
<span className="max-w-[100px] text-ellipsis overflow-hidden min-w-0 block">
{vendor.name}
{thirdParty.name}
</span>
{onRemove && (
<button
onClick={() => onRemove(vendor)}
onClick={() => onRemove(thirdParty)}
className="size-4 hover:text-txt-primary cursor-pointer"
type="button"
>

View File

@@ -18,7 +18,7 @@ import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { useVendorFormFragment$key } from "#/__generated__/core/useVendorFormFragment.graphql";
import type { useThirdPartyFormFragment$key } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
import { useFormWithSchema } from "../useFormWithSchema";
import { useMutationWithToasts } from "../useMutationWithToasts";
@@ -43,8 +43,8 @@ const schema = z.object({
securityOwnerId: z.string().nullish(),
});
const vendorFormFragment = graphql`
fragment useVendorFormFragment on Vendor {
const thirdPartyFormFragment = graphql`
fragment useThirdPartyFormFragment on ThirdParty {
id
name
description
@@ -70,46 +70,46 @@ const vendorFormFragment = graphql`
}
`;
const vendorUpdateQuery = graphql`
mutation useVendorFormMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor {
...useVendorFormFragment
const thirdPartyUpdateQuery = graphql`
mutation useThirdPartyFormMutation($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty {
...useThirdPartyFormFragment
}
}
}
`;
export function useVendorForm(vendorKey: useVendorFormFragment$key) {
const vendor = useFragment(vendorFormFragment, vendorKey);
export function useThirdPartyForm(thirdPartyKey: useThirdPartyFormFragment$key) {
const thirdParty = useFragment(thirdPartyFormFragment, thirdPartyKey);
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(vendorUpdateQuery, {
successMessage: __("Vendor updated successfully."),
errorMessage: __("Failed to update vendor"),
const [mutate] = useMutationWithToasts(thirdPartyUpdateQuery, {
successMessage: __("Third party updated successfully."),
errorMessage: __("Failed to update third party"),
});
const defaultValues = useMemo(
() => ({
name: vendor.name,
description: vendor.description || null,
category: vendor.category || null,
statusPageUrl: vendor.statusPageUrl || null,
termsOfServiceUrl: vendor.termsOfServiceUrl || null,
privacyPolicyUrl: vendor.privacyPolicyUrl || null,
serviceLevelAgreementUrl: vendor.serviceLevelAgreementUrl || null,
dataProcessingAgreementUrl: vendor.dataProcessingAgreementUrl || null,
websiteUrl: vendor.websiteUrl || null,
legalName: vendor.legalName || null,
headquarterAddress: vendor.headquarterAddress || null,
certifications: [...(vendor.certifications ?? [])],
countries: [...(vendor.countries ?? [])],
securityPageUrl: vendor.securityPageUrl || null,
trustPageUrl: vendor.trustPageUrl || null,
businessOwnerId: vendor.businessOwner?.id,
securityOwnerId: vendor.securityOwner?.id,
name: thirdParty.name,
description: thirdParty.description || null,
category: thirdParty.category || null,
statusPageUrl: thirdParty.statusPageUrl || null,
termsOfServiceUrl: thirdParty.termsOfServiceUrl || null,
privacyPolicyUrl: thirdParty.privacyPolicyUrl || null,
serviceLevelAgreementUrl: thirdParty.serviceLevelAgreementUrl || null,
dataProcessingAgreementUrl: thirdParty.dataProcessingAgreementUrl || null,
websiteUrl: thirdParty.websiteUrl || null,
legalName: thirdParty.legalName || null,
headquarterAddress: thirdParty.headquarterAddress || null,
certifications: [...(thirdParty.certifications ?? [])],
countries: [...(thirdParty.countries ?? [])],
securityPageUrl: thirdParty.securityPageUrl || null,
trustPageUrl: thirdParty.trustPageUrl || null,
businessOwnerId: thirdParty.businessOwner?.id,
securityOwnerId: thirdParty.securityOwner?.id,
}),
[vendor],
[thirdParty],
);
const form = useFormWithSchema(schema, {
@@ -120,7 +120,7 @@ export function useVendorForm(vendorKey: useVendorFormFragment$key) {
return mutate({
variables: {
input: {
id: vendor.id,
id: thirdParty.id,
...data,
description: data.description || null,
statusPageUrl: data.statusPageUrl || null,

View File

@@ -58,7 +58,7 @@ export const assetNodeQuery = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -94,7 +94,7 @@ export const createAssetMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -125,7 +125,7 @@ export const updateAssetMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -196,7 +196,7 @@ export const useCreateAsset = (connectionId: string) => {
assetType: AssetType;
ownerId: string;
organizationId: string;
vendorIds?: string[];
thirdPartyIds?: string[];
dataTypesStored: string;
}) => {
if (!input.name?.trim()) {
@@ -223,7 +223,7 @@ export const useCreateAsset = (connectionId: string) => {
dataTypesStored: input.dataTypesStored || "",
ownerId: input.ownerId,
organizationId: input.organizationId,
vendorIds: input.vendorIds || [],
thirdPartyIds: input.thirdPartyIds || [],
},
connections: [connectionId],
},
@@ -244,7 +244,7 @@ export const useUpdateAsset = () => {
assetType?: AssetType;
dataTypesStored?: string;
ownerId?: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update asset: asset ID is required"));

View File

@@ -49,7 +49,7 @@ export const datumNodeQuery = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -86,7 +86,7 @@ export const createDatumMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -115,7 +115,7 @@ export const updateDatumMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -186,7 +186,7 @@ export const useCreateDatum = (connectionId: string) => {
dataClassification: string;
ownerId: string;
organizationId: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.name?.trim()) {
return alert(__("Failed to create data: name is required"));
@@ -217,7 +217,7 @@ export const useUpdateDatum = () => {
name?: string;
dataClassification?: string;
ownerId?: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update data: missing id"));

View File

@@ -97,7 +97,7 @@ export const processingActivityNodeQuery = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -189,7 +189,7 @@ export const createProcessingActivityMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -236,7 +236,7 @@ export const updateProcessingActivityMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -322,7 +322,7 @@ export const useCreateProcessingActivity = (connectionId?: string) => {
nextReviewDate?: string;
role: string;
dataProtectionOfficerId?: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.organizationId) {
return alert(
@@ -359,7 +359,7 @@ export const useCreateProcessingActivity = (connectionId?: string) => {
nextReviewDate: input.nextReviewDate,
role: input.role,
dataProtectionOfficerId: input.dataProtectionOfficerId,
vendorIds: input.vendorIds,
thirdPartyIds: input.thirdPartyIds,
},
connections: connectionId ? [connectionId] : [],
},
@@ -393,7 +393,7 @@ export const useUpdateProcessingActivity = () => {
nextReviewDate?: string | null;
role?: string;
dataProtectionOfficerId?: string | null;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update processing activity: ID is required"));

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { 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 "../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 }
) {
thirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartiesListQuery_thirdParties") {
__id
edges {
node {
id
name
websiteUrl
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
canAssess: permission(action: "core:thirdParty:assess")
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"
)
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyContactsTabFragment
...ThirdPartyServicesTabFragment
...ThirdPartyRiskAssessmentTabFragment
...ThirdPartyOverviewTabBusinessAssociateAgreementFragment
...ThirdPartyOverviewTabDataPrivacyAgreementFragment
}
}
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
}
}
}
}
}
}
`;
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

@@ -1,246 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMemo } from "react";
import { useLazyLoadQuery, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { VendorGraphCreateMutation } from "#/__generated__/core/VendorGraphCreateMutation.graphql";
import type { VendorGraphDeleteMutation } from "#/__generated__/core/VendorGraphDeleteMutation.graphql";
import type { VendorGraphSelectQuery } from "#/__generated__/core/VendorGraphSelectQuery.graphql";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
const createVendorMutation = graphql`
mutation VendorGraphCreateMutation(
$input: CreateVendorInput!
$connections: [ID!]!
) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
description
websiteUrl
createdAt
updatedAt
canUpdate: permission(action: "core:vendor:update")
canDelete: permission(action: "core:vendor:delete")
}
}
}
}
`;
export function useCreateVendorMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<VendorGraphCreateMutation>(
createVendorMutation,
{
successMessage: __("Vendor created successfully."),
errorMessage: __("Failed to create vendor"),
},
);
}
const deleteVendorMutation = graphql`
mutation VendorGraphDeleteMutation(
$input: DeleteVendorInput!
$connections: [ID!]!
) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteVendor = (
vendor: { id?: string; name?: string },
connectionId: string,
) => {
const [mutate] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!vendor.id || !vendor.name) {
return alert(__("Failed to delete vendor: missing id or name"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
vendorId: vendor.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete vendor \"%s\". This action cannot be undone.",
),
vendor.name,
),
},
);
};
};
export const vendorConnectionKey = "VendorsPage_vendors";
export const vendorsQuery = graphql`
query VendorGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateVendor: permission(action: "core:vendor:create")
canPublishVendor: permission(action: "core:vendor:publish")
vendorsDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
...VendorGraphPaginatedFragment
}
}
}
`;
export const paginatedVendorsFragment = graphql`
fragment VendorGraphPaginatedFragment on Organization
@refetchable(queryName: "VendorsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
vendors(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorsListQuery_vendors") {
__id
edges {
node {
id
name
websiteUrl
updatedAt
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
id
createdAt
expiresAt
dataSensitivity
businessImpact
}
}
}
canUpdate: permission(action: "core:vendor:update")
canDelete: permission(action: "core:vendor:delete")
}
}
}
}
`;
export const vendorNodeQuery = graphql`
query VendorGraphNodeQuery($vendorId: ID!) {
node(id: $vendorId) {
id
... on Vendor {
name
websiteUrl
canAssess: permission(action: "core:vendor:assess")
canUpdate: permission(action: "core:vendor:update")
canDelete: permission(action: "core:vendor:delete")
canUploadComplianceReport: permission(
action: "core:vendor-compliance-report:upload"
)
canCreateRiskAssessment: permission(
action: "core:vendor-risk-assessment:create"
)
canCreateContact: permission(action: "core:vendor-contact:create")
canCreateService: permission(action: "core:vendor-service:create")
canUploadBAA: permission(
action: "core:vendor-business-associate-agreement:upload"
)
canUploadDPA: permission(
action: "core:vendor-data-privacy-agreement:upload"
)
...useVendorFormFragment
...VendorComplianceTabFragment
...VendorContactsTabFragment
...VendorServicesTabFragment
...VendorRiskAssessmentTabFragment
...VendorOverviewTabBusinessAssociateAgreementFragment
...VendorOverviewTabDataPrivacyAgreementFragment
}
}
viewer {
id
}
}
`;
export const vendorsSelectQuery = graphql`
query VendorGraphSelectQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
vendors(first: 100, orderBy: { direction: ASC, field: NAME }) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
export function useVendors(organizationId: string) {
const data = useLazyLoadQuery<VendorGraphSelectQuery>(
vendorsSelectQuery,
{
organizationId: organizationId,
},
{ fetchPolicy: "network-only" },
);
return useMemo(() => {
return data.organization?.vendors?.edges.map(edge => edge.node) ?? [];
}, [data]);
}

View File

@@ -50,7 +50,7 @@ const fragment = graphql`
canListRisks: permission(action: "core:risk:list")
canListFrameworks: permission(action: "core:framework:list")
canListMembers: permission(action: "iam:membership:list")
canListVendors: permission(action: "core:vendor:list")
canListThirdParties: permission(action: "core:thirdParty:list")
canListDocuments: permission(action: "core:document:list")
canListAssets: permission(action: "core:asset:list")
canListData: permission(action: "core:datum:list")
@@ -127,11 +127,11 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/people`}
/>
)}
{organization.canListVendors && (
{organization.canListThirdParties && (
<SidebarItem
label={__("Vendors")}
label={__("Third parties")}
icon={IconStore}
to={`${prefix}/vendors`}
to={`${prefix}/third-parties`}
/>
)}
{organization.canListDocuments && (

View File

@@ -28,9 +28,9 @@ import {
Input,
Option,
Select,
ThirdPartyLogo,
useDialogRef,
useToast,
VendorLogo,
} from "@probo/ui";
import { type ReactNode, useMemo, useState } from "react";
import { useMutation } from "react-relay";
@@ -454,7 +454,7 @@ export function AddAccessSourceDialog({
return (
<Card key={info.provider} padded className="flex items-center gap-3">
<VendorLogo vendor={info.provider} tint className="size-6 shrink-0" />
<ThirdPartyLogo thirdParty={info.provider} tint className="size-6 shrink-0" />
<div className="mr-auto">
<h3 className="font-medium">{info.displayName}</h3>
</div>

View File

@@ -34,7 +34,7 @@ import { z } from "zod";
import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -50,7 +50,7 @@ const updateAssetSchema = z.object({
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
dataTypesStored: z.string().min(1, "Data types stored is required"),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -72,8 +72,8 @@ export default function AssetDetailsPage(props: Props) {
);
const deleteAsset = useDeleteAsset(assetEntry, connectionId);
const vendors = assetEntry.vendors?.edges.map(edge => edge.node) ?? [];
const vendorIds = vendors.map(vendor => vendor.id);
const thirdParties = assetEntry.thirdParties?.edges.map(edge => edge.node) ?? [];
const thirdPartyIds = thirdParties.map(thirdParty => thirdParty.id);
const { control, formState, handleSubmit, register, reset }
= useFormWithSchema(updateAssetSchema, {
@@ -83,7 +83,7 @@ export default function AssetDetailsPage(props: Props) {
assetType: assetEntry.assetType || "VIRTUAL",
dataTypesStored: assetEntry.dataTypesStored || "",
ownerId: assetEntry.owner?.id || "",
vendorIds: vendorIds,
thirdPartyIds: thirdPartyIds,
},
});
@@ -176,12 +176,12 @@ export default function AssetDetailsPage(props: Props) {
disabled={!assetEntry.canUpdate}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
name="thirdPartyIds"
selectedThirdParties={thirdParties}
label={__("Third parties")}
disabled={!assetEntry.canUpdate}
/>

View File

@@ -78,7 +78,7 @@ const paginatedAssetsFragment = graphql`
fullName
}
# eslint-disable-next-line relay/unused-fields
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
# eslint-disable-next-line relay/unused-fields

View File

@@ -27,7 +27,7 @@ import { z } from "zod";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useCreateAsset } from "#/hooks/graph/AssetGraph";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
@@ -36,7 +36,7 @@ const schema = z.object({
amount: z.number().min(1, "Amount is required"),
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
dataTypesStored: z.string().min(1, "Data types stored is required"),
});
@@ -59,7 +59,7 @@ export function CreateAssetDialog({
amount: 0,
assetType: "VIRTUAL",
ownerId: "",
vendorIds: [],
thirdPartyIds: [],
},
});
const ref = useDialogRef();
@@ -112,11 +112,11 @@ export function CreateAssetDialog({
name="ownerId"
label={__("Owner")}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
label={__("Vendors")}
name="thirdPartyIds"
label={__("Third parties")}
/>
</DialogContent>
<DialogFooter>

View File

@@ -113,7 +113,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
<IconFolder2 className="size-4" />
{__("Files")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/compliance-page/vendors`}>
<TabLink to={`/organizations/${organizationId}/compliance-page/third-parties`}>
<IconStore className="size-4" />
{__("Subprocessors")}
</TabLink>

View File

@@ -62,9 +62,9 @@ export const compliancePageRoutes = [
Component: lazy(() => import("#/pages/organizations/compliance-page/files/CompliancePageFilesPageLoader")),
},
{
path: "vendors",
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/compliance-page/vendors/CompliancePageVendorsPageLoader")),
Component: lazy(() => import("#/pages/organizations/compliance-page/third-parties/CompliancePageThirdPartiesPageLoader")),
},
{
path: "access",

View File

@@ -15,27 +15,27 @@
import { useTranslate } from "@probo/i18n";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { CompliancePageVendorsPageQuery } from "#/__generated__/core/CompliancePageVendorsPageQuery.graphql";
import type { CompliancePageThirdPartiesPageQuery } from "#/__generated__/core/CompliancePageThirdPartiesPageQuery.graphql";
import { CompliancePageVendorList } from "./_components/CompliancePageVendorList";
import { CompliancePageThirdPartyList } from "./_components/CompliancePageThirdPartyList";
export const compliancePageVendorsPageQuery = graphql`
query CompliancePageVendorsPageQuery($organizationId: ID!) {
export const compliancePageThirdPartiesPageQuery = graphql`
query CompliancePageThirdPartiesPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
...CompliancePageVendorListFragment
...CompliancePageThirdPartyListFragment
}
}
`;
export function CompliancePageVendorsPage(props: {
queryRef: PreloadedQuery<CompliancePageVendorsPageQuery>;
export function CompliancePageThirdPartiesPage(props: {
queryRef: PreloadedQuery<CompliancePageThirdPartiesPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
const { organization } = usePreloadedQuery<CompliancePageVendorsPageQuery>(
compliancePageVendorsPageQuery,
const { organization } = usePreloadedQuery<CompliancePageThirdPartiesPageQuery>(
compliancePageThirdPartiesPageQuery,
queryRef,
);
@@ -50,7 +50,7 @@ export function CompliancePageVendorsPage(props: {
</div>
</div>
<CompliancePageVendorList fragmentRef={organization} />
<CompliancePageThirdPartyList fragmentRef={organization} />
</div>
);
}

View File

@@ -15,16 +15,18 @@
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { CompliancePageVendorsPageQuery } from "#/__generated__/core/CompliancePageVendorsPageQuery.graphql";
import type { CompliancePageThirdPartiesPageQuery } from "#/__generated__/core/CompliancePageThirdPartiesPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import { CompliancePageVendorsPage, compliancePageVendorsPageQuery } from "./CompliancePageVendorsPage";
import { CompliancePageThirdPartiesPage, compliancePageThirdPartiesPageQuery } from "./CompliancePageThirdPartiesPage";
function CompliancePageVendorsPageQueryLoader() {
function CompliancePageThirdPartiesPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<CompliancePageVendorsPageQuery>(compliancePageVendorsPageQuery);
const [queryRef, loadQuery] = useQueryLoader<CompliancePageThirdPartiesPageQuery>(
compliancePageThirdPartiesPageQuery,
);
useEffect(() => {
if (!queryRef) {
@@ -34,13 +36,13 @@ function CompliancePageVendorsPageQueryLoader() {
if (!queryRef) return <LinkCardSkeleton />;
return <CompliancePageVendorsPage queryRef={queryRef} />;
return <CompliancePageThirdPartiesPage queryRef={queryRef} />;
}
export default function CompliancePageVendorsPageLoader() {
export default function CompliancePageThirdPartiesPageLoader() {
return (
<CoreRelayProvider>
<CompliancePageVendorsPageQueryLoader />
<CompliancePageThirdPartiesPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -17,29 +17,29 @@ import { Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageVendorListFragment$key } from "#/__generated__/core/CompliancePageVendorListFragment.graphql";
import type { CompliancePageThirdPartyListFragment$key } from "#/__generated__/core/CompliancePageThirdPartyListFragment.graphql";
import { CompliancePageVendorListItem } from "./CompliancePageVendorListItem";
import { CompliancePageThirdPartyListItem } from "./CompliancePageThirdPartyListItem";
const fragment = graphql`
fragment CompliancePageVendorListFragment on Organization {
vendors(first: 100) {
fragment CompliancePageThirdPartyListFragment on Organization {
thirdParties(first: 100) {
edges {
node {
id
...CompliancePageVendorListItem_vendorFragment
...CompliancePageThirdPartyListItem_thirdPartyFragment
}
}
}
}
`;
export function CompliancePageVendorList(props: { fragmentRef: CompliancePageVendorListFragment$key }) {
export function CompliancePageThirdPartyList(props: { fragmentRef: CompliancePageThirdPartyListFragment$key }) {
const { fragmentRef } = props;
const { __ } = useTranslate();
const { vendors } = useFragment<CompliancePageVendorListFragment$key>(fragment, fragmentRef);
const { thirdParties } = useFragment<CompliancePageThirdPartyListFragment$key>(fragment, fragmentRef);
return (
<div className="space-y-[10px]">
@@ -53,17 +53,17 @@ export function CompliancePageVendorList(props: { fragmentRef: CompliancePageVen
</Tr>
</Thead>
<Tbody>
{vendors.edges.length === 0 && (
{thirdParties.edges.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No subprocessors available")}
</Td>
</Tr>
)}
{vendors.edges.map(({ node: vendor }) => (
<CompliancePageVendorListItem
key={vendor.id}
vendorFragmentRef={vendor}
{thirdParties.edges.map(({ node: thirdParty }) => (
<CompliancePageThirdPartyListItem
key={thirdParty.id}
thirdPartyFragmentRef={thirdParty}
/>
))}
</Tbody>

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Badge, Button, IconCheckmark1, IconCrossLargeX, Td, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageThirdPartyListItem_thirdPartyFragment$key } from "#/__generated__/core/CompliancePageThirdPartyListItem_thirdPartyFragment.graphql";
import type { CompliancePageThirdPartyListItemMutation } from "#/__generated__/core/CompliancePageThirdPartyListItemMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const thirdPartyFragment = graphql`
fragment CompliancePageThirdPartyListItem_thirdPartyFragment on ThirdParty {
id
category
name
showOnTrustCenter
canUpdate: permission(action: "core:thirdParty:update")
}
`;
const updateThirdPartyVisibilityMutation = graphql`
mutation CompliancePageThirdPartyListItemMutation($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty {
id
showOnTrustCenter
...CompliancePageThirdPartyListItem_thirdPartyFragment
}
}
}
`;
export function CompliancePageThirdPartyListItem(props: {
thirdPartyFragmentRef: CompliancePageThirdPartyListItem_thirdPartyFragment$key;
}) {
const { thirdPartyFragmentRef } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const thirdParty = useFragment<CompliancePageThirdPartyListItem_thirdPartyFragment$key>(
thirdPartyFragment,
thirdPartyFragmentRef,
);
const [updateThirdPartyVisibility, isUpadtingThirdPartyVisibility] = useMutationWithToasts<
CompliancePageThirdPartyListItemMutation
>(
updateThirdPartyVisibilityMutation,
{
successMessage: __("Subprocessor visibility updated successfully."),
errorMessage: __("Failed to update subprocessor visibility"),
},
);
return (
<Tr to={`/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`}>
<Td>
<div className="flex gap-4 items-center">{thirdParty.name}</div>
</Td>
<Td>
<Badge variant="neutral">{thirdParty.category}</Badge>
</Td>
<Td>
<Badge variant={thirdParty.showOnTrustCenter ? "success" : "danger"}>
{thirdParty.showOnTrustCenter ? __("Visible") : __("None")}
</Badge>
</Td>
<Td noLink width={100} className="text-end">
{thirdParty.canUpdate && (
<Button
variant="secondary"
onClick={() =>
void updateThirdPartyVisibility({
variables: {
input: {
id: thirdParty.id,
showOnTrustCenter: !thirdParty.showOnTrustCenter,
},
},
})}
icon={thirdParty.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
disabled={isUpadtingThirdPartyVisibility}
>
{thirdParty.showOnTrustCenter ? __("Hide") : __("Show")}
</Button>
)}
</Td>
</Tr>
);
};

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Badge, Button, IconCheckmark1, IconCrossLargeX, Td, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageVendorListItem_vendorFragment$key } from "#/__generated__/core/CompliancePageVendorListItem_vendorFragment.graphql";
import type { CompliancePageVendorListItemMutation } from "#/__generated__/core/CompliancePageVendorListItemMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const vendorFragment = graphql`
fragment CompliancePageVendorListItem_vendorFragment on Vendor {
id
category
name
showOnTrustCenter
canUpdate: permission(action: "core:vendor:update")
}
`;
const updateVendorVisibilityMutation = graphql`
mutation CompliancePageVendorListItemMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor {
id
showOnTrustCenter
...CompliancePageVendorListItem_vendorFragment
}
}
}
`;
export function CompliancePageVendorListItem(props: {
vendorFragmentRef: CompliancePageVendorListItem_vendorFragment$key;
}) {
const { vendorFragmentRef } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const vendor = useFragment<CompliancePageVendorListItem_vendorFragment$key>(
vendorFragment,
vendorFragmentRef,
);
const [updateVendorVisibility, isUpadtingVendorVisibility] = useMutationWithToasts<
CompliancePageVendorListItemMutation
>(
updateVendorVisibilityMutation,
{
successMessage: __("Subprocessor visibility updated successfully."),
errorMessage: __("Failed to update subprocessor visibility"),
},
);
return (
<Tr to={`/organizations/${organizationId}/vendors/${vendor.id}/overview`}>
<Td>
<div className="flex gap-4 items-center">{vendor.name}</div>
</Td>
<Td>
<Badge variant="neutral">{vendor.category}</Badge>
</Td>
<Td>
<Badge variant={vendor.showOnTrustCenter ? "success" : "danger"}>
{vendor.showOnTrustCenter ? __("Visible") : __("None")}
</Badge>
</Td>
<Td noLink width={100} className="text-end">
{vendor.canUpdate && (
<Button
variant="secondary"
onClick={() =>
void updateVendorVisibility({
variables: {
input: {
id: vendor.id,
showOnTrustCenter: !vendor.showOnTrustCenter,
},
},
})}
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
disabled={isUpadtingVendorVisibility}
>
{vendor.showOnTrustCenter ? __("Hide") : __("Show")}
</Button>
)}
</Td>
</Tr>
);
};

View File

@@ -81,7 +81,7 @@ const paginatedDataFragment = graphql`
owner {
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -192,7 +192,7 @@ export default function DataPage(props: Props) {
<Th>{__("Name")}</Th>
<Th>{__("Classification")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
<Th>{__("Third parties")}</Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
@@ -223,7 +223,7 @@ function DataRow({
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const deleteDatum = useDeleteDatum(entry, connectionId);
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
const thirdParties = entry.thirdParties?.edges.map(edge => edge.node) ?? [];
const detailUrl = `/organizations/${organizationId}/data/${entry.id}`;
return (
@@ -234,27 +234,27 @@ function DataRow({
</Td>
<Td>{entry.owner?.fullName ?? __("Unassigned")}</Td>
<Td>
{vendors.length > 0
{thirdParties.length > 0
? (
<div className="flex flex-wrap gap-1">
{vendors.slice(0, 3).map(vendor => (
{thirdParties.slice(0, 3).map(thirdParty => (
<Badge
key={vendor.id}
key={thirdParty.id}
variant="neutral"
className="flex items-center gap-1"
>
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<span className="text-xs">{vendor.name}</span>
<span className="text-xs">{thirdParty.name}</span>
</Badge>
))}
{vendors.length > 3 && (
{thirdParties.length > 3 && (
<Badge variant="neutral" className="text-xs">
+
{vendors.length - 3}
{thirdParties.length - 3}
</Badge>
)}
</div>

View File

@@ -33,7 +33,7 @@ import { z } from "zod";
import type { DatumGraphNodeQuery } from "#/__generated__/core/DatumGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import {
datumNodeQuery,
useDeleteDatum,
@@ -46,7 +46,7 @@ const updateDatumSchema = z.object({
name: z.string().min(1, "Name is required"),
dataClassification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -69,8 +69,8 @@ export default function DatumDetailsPage(props: Props) {
ConnectionHandler.getConnectionID(organizationId, "DataPage_data"),
);
const vendors = datumEntry?.vendors?.edges.map(edge => edge.node) ?? [];
const vendorIds = vendors.map(vendor => vendor.id);
const thirdParties = datumEntry?.thirdParties?.edges.map(edge => edge.node) ?? [];
const thirdPartyIds = thirdParties.map(thirdParty => thirdParty.id);
const { control, formState, handleSubmit, register, reset }
= useFormWithSchema(updateDatumSchema, {
@@ -78,7 +78,7 @@ export default function DatumDetailsPage(props: Props) {
name: datumEntry?.name || "",
dataClassification: datumEntry?.dataClassification || "PUBLIC",
ownerId: datumEntry?.owner?.id || "",
vendorIds: vendorIds,
thirdPartyIds: thirdPartyIds,
},
});
@@ -161,13 +161,13 @@ export default function DatumDetailsPage(props: Props) {
disabled={!datumEntry.canUpdate}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
label={__("Vendors")}
name="thirdPartyIds"
label={__("Third parties")}
disabled={!datumEntry.canUpdate}
selectedVendors={vendors}
selectedThirdParties={thirdParties}
/>
<div className="flex justify-end">

View File

@@ -27,7 +27,7 @@ import { z } from "zod";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useCreateDatum } from "../../../../hooks/graph/DatumGraph";
@@ -36,7 +36,7 @@ const schema = z.object({
name: z.string().min(1, "Name is required"),
dataClassification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -59,7 +59,7 @@ export function CreateDatumDialog({
name: "",
dataClassification: "PUBLIC",
ownerId: "",
vendorIds: [],
thirdPartyIds: [],
},
});
const ref = useDialogRef();
@@ -105,11 +105,11 @@ export function CreateDatumDialog({
name="ownerId"
label={__("Owner")}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
label={__("Vendors")}
name="thirdPartyIds"
label={__("Third parties")}
/>
</DialogContent>
<DialogFooter>

View File

@@ -46,7 +46,7 @@ import { z } from "zod";
import type { ProcessingActivityGraphNodeQuery } from "#/__generated__/core/ProcessingActivityGraphNodeQuery.graphql";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -102,7 +102,7 @@ const updateProcessingActivitySchema = z.object({
nextReviewDate: z.string().optional(),
role: z.enum(["CONTROLLER", "PROCESSOR"] as const),
dataProtectionOfficerId: z.string().optional(),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -199,8 +199,8 @@ export default function ProcessingActivityDetailsPage(props: Props) {
connectionId,
);
const vendors = activity?.vendors?.edges.map(edge => edge.node) ?? [];
const vendorIds = vendors.map(vendor => vendor.id);
const thirdParties = activity?.thirdParties?.edges.map(edge => edge.node) ?? [];
const thirdPartyIds = thirdParties.map(thirdParty => thirdParty.id);
const { register, handleSubmit, formState, control } = useFormWithSchema(
updateProcessingActivitySchema,
@@ -229,7 +229,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
nextReviewDate: toDateInput(activity.nextReviewDate),
role: activity.role || ("CONTROLLER" as const),
dataProtectionOfficerId: activity.dataProtectionOfficer?.id || "",
vendorIds: vendorIds,
thirdPartyIds: thirdPartyIds,
},
},
);
@@ -287,7 +287,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
nextReviewDate: formatDatetime(formData.nextReviewDate) ?? null,
role: formData.role,
dataProtectionOfficerId: formData.dataProtectionOfficerId || null,
vendorIds: formData.vendorIds,
thirdPartyIds: formData.thirdPartyIds,
});
toast({
@@ -777,12 +777,12 @@ export default function ProcessingActivityDetailsPage(props: Props) {
</div>
</div>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
name="thirdPartyIds"
selectedThirdParties={thirdParties}
label={__("Third parties")}
disabled={!activity.canUpdate}
/>

View File

@@ -34,7 +34,7 @@ import { Controller } from "react-hook-form";
import { z } from "zod";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import {
@@ -67,7 +67,7 @@ const schema = z.object({
nextReviewDate: z.string().optional(),
role: z.enum(["CONTROLLER", "PROCESSOR"] as const),
dataProtectionOfficerId: z.string().optional(),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type FormData = z.infer<typeof schema>;
@@ -110,7 +110,7 @@ export function CreateProcessingActivityDialog({
nextReviewDate: "",
role: "PROCESSOR" as const,
dataProtectionOfficerId: "",
vendorIds: [],
thirdPartyIds: [],
},
});
@@ -137,7 +137,7 @@ export function CreateProcessingActivityDialog({
nextReviewDate: formatDatetime(formData.nextReviewDate),
role: formData.role,
dataProtectionOfficerId: formData.dataProtectionOfficerId || undefined,
vendorIds: formData.vendorIds,
thirdPartyIds: formData.thirdPartyIds,
});
toast({
@@ -430,12 +430,12 @@ export function CreateProcessingActivityDialog({
</div>
</div>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
selectedVendors={[]}
label={__("Vendors")}
name="thirdPartyIds"
selectedThirdParties={[]}
label={__("Third parties")}
/>
</DialogContent>

View File

@@ -155,9 +155,9 @@ const deleteWebhookSubscriptionMutation = graphql`
`;
const EVENT_TYPES = [
{ value: "VENDOR_CREATED", label: "vendor:created" },
{ value: "VENDOR_UPDATED", label: "vendor:updated" },
{ value: "VENDOR_DELETED", label: "vendor:deleted" },
{ value: "THIRD_PARTY_CREATED", label: "third-party:created" },
{ value: "THIRD_PARTY_UPDATED", label: "third-party:updated" },
{ value: "THIRD_PARTY_DELETED", label: "third-party:deleted" },
{ value: "USER_CREATED", label: "user:created" },
{ value: "USER_UPDATED", label: "user:updated" },
{ value: "USER_DELETED", label: "user:deleted" },

View File

@@ -39,75 +39,75 @@ import {
} from "react-relay";
import { useNavigate } from "react-router";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
import type {
VendorGraphPaginatedFragment$data,
VendorGraphPaginatedFragment$key,
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
ThirdPartyGraphPaginatedFragment$data,
ThirdPartyGraphPaginatedFragment$key,
} from "#/__generated__/core/ThirdPartyGraphPaginatedFragment.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
paginatedVendorsFragment,
useDeleteVendor,
vendorsQuery,
} from "#/hooks/graph/VendorGraph";
paginatedThirdPartiesFragment,
thirdPartiesQuery,
useDeleteThirdParty,
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
import { CreateThirdPartyDialog } from "./dialogs/CreateThirdPartyDialog";
import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDialog";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
type ThirdParty = NodeOf<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
type Props = {
queryRef: PreloadedQuery<VendorGraphListQuery>;
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
};
export default function VendorsPage(props: Props) {
export default function ThirdPartiesPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
const data = usePreloadedQuery(thirdPartiesQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types
const pagination = usePaginationFragment(
paginatedVendorsFragment,
data.node as VendorGraphPaginatedFragment$key,
paginatedThirdPartiesFragment,
data.node as ThirdPartyGraphPaginatedFragment$key,
);
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
const connectionId = pagination.data.vendors.__id;
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id;
usePageTitle(__("Vendors"));
usePageTitle(__("Third parties"));
const hasAnyAction
= vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
const vendorsDocument = data.node?.vendorsDocument;
const thirdPartiesDocument = data.node?.thirdPartiesDocument;
const defaultApproverIds
= vendorsDocument?.defaultApprovers?.map(a => a.id) ?? [];
= thirdPartiesDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
<div className="space-y-6">
<PageHeader
title={__("Vendors")}
title={__("Third parties")}
description={__(
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
"Third parties are external services and providers that your company uses. Add them to keep track of their risk and compliance status.",
)}
>
<div className="flex gap-2">
{vendorsDocument && (
{thirdPartiesDocument && (
<Button
variant="secondary"
icon={IconPageTextLine}
onClick={() => void navigate(
`/organizations/${organizationId}/documents/${vendorsDocument.id}`,
`/organizations/${organizationId}/documents/${thirdPartiesDocument.id}`,
)}
>
{__("Document")}
</Button>
)}
{data.node.canPublishVendor && (
<PublishVendorListDialog
{data.node.canPublishThirdParty && (
<PublishThirdPartyListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={documentId => void navigate(
@@ -117,22 +117,22 @@ export default function VendorsPage(props: Props) {
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishVendorListDialog>
</PublishThirdPartyListDialog>
)}
{data.node.canCreateVendor && (
<CreateVendorDialog
{data.node.canCreateThirdParty && (
<CreateThirdPartyDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
</CreateThirdPartyDialog>
)}
</div>
</PageHeader>
<SortableTable {...pagination}>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Vendor")}</SortableTh>
<SortableTh field="NAME">{__("Third party")}</SortableTh>
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
@@ -140,10 +140,10 @@ export default function VendorsPage(props: Props) {
</Tr>
</Thead>
<Tbody>
{vendors?.map(vendor => (
<VendorRow
key={vendor.id}
vendor={vendor}
{thirdParties?.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
organizationId={organizationId}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
@@ -155,30 +155,30 @@ export default function VendorsPage(props: Props) {
);
}
function VendorRow({
vendor,
function ThirdPartyRow({
thirdParty,
organizationId,
connectionId,
hasAnyAction,
}: {
vendor: Vendor;
thirdParty: ThirdParty;
organizationId: string;
connectionId: string;
hasAnyAction: boolean;
}) {
const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const deleteVendor = useDeleteVendor(vendor, connectionId);
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
return (
<>
<Tr to={vendorUrl}>
<Tr to={thirdPartyUrl}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
<div>{vendor.name}</div>
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
<div>{thirdParty.name}</div>
</div>
</Td>
<Td>
@@ -195,9 +195,9 @@ function VendorRow({
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{vendor.canDelete && (
{thirdParty.canDelete && (
<DropdownItem
onClick={deleteVendor}
onClick={deleteThirdParty}
variant="danger"
icon={IconTrashCan}
>

View File

@@ -33,52 +33,52 @@ import {
} from "react-relay";
import { Outlet } from "react-router";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import {
useDeleteVendor,
vendorConnectionKey,
vendorNodeQuery,
} from "#/hooks/graph/VendorGraph";
thirdPartyConnectionKey,
thirdPartyNodeQuery,
useDeleteThirdParty,
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { complianceReportsFragment } from "./tabs/VendorComplianceTab";
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
type Props = {
queryRef: PreloadedQuery<VendorGraphNodeQuery>;
queryRef: PreloadedQuery<ThirdPartyGraphNodeQuery>;
};
export default function VendorDetailPage(props: Props) {
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
export default function ThirdPartyDetailPage(props: Props) {
const { node: thirdParty } = usePreloadedQuery(thirdPartyNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const deleteVendor = useDeleteVendor(
vendor,
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
const deleteThirdParty = useDeleteThirdParty(
thirdParty,
ConnectionHandler.getConnectionID(organizationId, thirdPartyConnectionKey),
);
const logo = faviconUrl(vendor.websiteUrl);
const logo = faviconUrl(thirdParty.websiteUrl);
const reportsCount = useFragment(
complianceReportsFragment,
vendor as VendorComplianceTabFragment$key,
thirdParty as ThirdPartyComplianceTabFragment$key,
).complianceReports.edges.length;
const vendorsUrl = `/organizations/${organizationId}/vendors`;
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
const baseVendorUrl
= `/organizations/${organizationId}/vendors/${vendor.id}`;
const baseThirdPartyUrl
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("Vendors"),
to: vendorsUrl,
label: __("Third parties"),
to: thirdPartiesUrl,
},
{
label: vendor.name ?? "",
label: thirdParty.name ?? "",
},
]}
/>
@@ -87,26 +87,26 @@ export default function VendorDetailPage(props: Props) {
{logo && (
<img
src={logo}
alt={vendor.name ?? ""}
alt={thirdParty.name ?? ""}
className="shadow-mid rounded-2xl"
/>
)}
<div className="text-2xl">{vendor.name}</div>
<div className="text-2xl">{thirdParty.name}</div>
</div>
<div className="flex gap-2 items-center">
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id}>
{thirdParty.canAssess && (
<ImportAssessmentDialog thirdPartyId={thirdParty.id}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{vendor.canDelete && (
{thirdParty.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
onClick={deleteThirdParty}
>
{__("Delete")}
</DropdownItem>
@@ -116,20 +116,20 @@ export default function VendorDetailPage(props: Props) {
</div>
<Tabs>
<TabLink to={`${baseVendorUrl}/overview`}>{__("Overview")}</TabLink>
<TabLink to={`${baseVendorUrl}/certifications`}>
<TabLink to={`${baseThirdPartyUrl}/overview`}>{__("Overview")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/certifications`}>
{__("Certifications")}
</TabLink>
<TabLink to={`${baseVendorUrl}/compliance`}>
<TabLink to={`${baseThirdPartyUrl}/compliance`}>
{__("Compliance reports")}
{reportsCount > 0 && <TabBadge>{reportsCount}</TabBadge>}
</TabLink>
<TabLink to={`${baseVendorUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseVendorUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseVendorUrl}/services`}>{__("Services")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
</Tabs>
<Outlet context={{ vendor }} />
<Outlet context={{ thirdParty }} />
</div>
);
}

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 { CreateVendorInput } from "#/__generated__/core/VendorGraphCreateMutation.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
export type CommonThirdPartyRef
= CommonThirdPartyCombobox_commonThirdParty$data;
@@ -59,7 +59,7 @@ export const commonThirdPartiesQuery = graphql`
interface CommonThirdPartyComboboxProps {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
onSelect: (thridParty: Omit<CreateVendorInput, "organizationId">) => void;
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
}
export function CommonThirdPartyCombobox({

View File

@@ -33,20 +33,20 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connectionId: string;
vendorId: string;
thirdPartyId: string;
};
const createContactMutation = graphql`
mutation CreateContactDialogMutation(
$input: CreateVendorContactInput!
$input: CreateThirdPartyContactInput!
$connections: [ID!]!
) {
createVendorContact(input: $input) {
vendorContactEdge @prependEdge(connections: $connections) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge @prependEdge(connections: $connections) {
node {
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
...VendorContactsTabFragment_contact
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactsTabFragment_contact
}
}
}
@@ -58,7 +58,7 @@ const phoneRegex = /^\+[0-9]{8,15}$/;
export function CreateContactDialog({
children,
connectionId,
vendorId,
thirdPartyId,
}: Props) {
const { __ } = useTranslate();
@@ -107,7 +107,7 @@ export function CreateContactDialog({
await createContact({
variables: {
input: {
vendorId,
thirdPartyId,
...cleanData,
},
connections: [connectionId],

View File

@@ -35,18 +35,18 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connection: string;
vendorId: string;
thirdPartyId: string;
};
const createRiskAssessmentMutation = graphql`
mutation CreateRiskAssessmentDialogMutation(
$input: CreateVendorRiskAssessmentInput!
$input: CreateThirdPartyRiskAssessmentInput!
$connections: [ID!]!
) {
createVendorRiskAssessment(input: $input) {
vendorRiskAssessmentEdge @prependEdge(connections: $connections) {
createThirdPartyRiskAssessment(input: $input) {
thirdPartyRiskAssessmentEdge @prependEdge(connections: $connections) {
node {
...VendorRiskAssessmentTabFragment_assessment
...ThirdPartyRiskAssessmentTabFragment_assessment
}
}
}
@@ -65,7 +65,7 @@ const schema = z.object({
export function CreateRiskAssessmentDialog({
children,
connection,
vendorId,
thirdPartyId,
}: Props) {
const { __ } = useTranslate();
@@ -92,7 +92,7 @@ export function CreateRiskAssessmentDialog({
input: {
...data,
notes: data.notes || null,
vendorId,
thirdPartyId,
expiresAt: nextYear.toISOString(),
},
connections: [connection],

View File

@@ -33,18 +33,18 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connectionId: string;
vendorId: string;
thirdPartyId: string;
};
const createServiceMutation = graphql`
mutation CreateServiceDialogMutation(
$input: CreateVendorServiceInput!
$input: CreateThirdPartyServiceInput!
$connections: [ID!]!
) {
createVendorService(input: $input) {
vendorServiceEdge @prependEdge(connections: $connections) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge @prependEdge(connections: $connections) {
node {
...VendorServicesTabFragment_service
...ThirdPartyServicesTabFragment_service
}
}
}
@@ -54,7 +54,7 @@ const createServiceMutation = graphql`
export function CreateServiceDialog({
children,
connectionId,
vendorId,
thirdPartyId,
}: Props) {
const { __ } = useTranslate();
@@ -86,7 +86,7 @@ export function CreateServiceDialog({
await createService({
variables: {
input: {
vendorId,
thirdPartyId,
...cleanData,
},
connections: [connectionId],

View File

@@ -27,8 +27,8 @@ import { useQueryLoader } from "react-relay";
import { useDebounceCallback } from "usehooks-ts";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateVendorInput } from "#/__generated__/core/VendorGraphCreateMutation.graphql";
import { useCreateVendorMutation } from "#/hooks/graph/VendorGraph";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
import {
commonThirdPartiesQuery,
@@ -41,19 +41,19 @@ type Props = {
connection: string;
};
export function CreateVendorDialog({
export function CreateThirdPartyDialog({
children,
organizationId,
connection,
}: Props) {
const { __ } = useTranslate();
const [createVendor] = useCreateVendorMutation();
const [createThirdParty] = useCreateThirdPartyMutation();
const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery]
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const onSelect = async (thirdParty: Omit<CreateVendorInput, "organizationId"> | string) => {
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
const input
= typeof thirdParty === "string"
? {
@@ -65,7 +65,7 @@ export function CreateVendorDialog({
...thirdParty,
organizationId,
};
await createVendor({
await createThirdParty({
variables: {
input,
connections: [connection],
@@ -95,9 +95,9 @@ export function CreateVendorDialog({
};
return (
<Dialog ref={dialogRef} trigger={children} title={__("Add a vendor")}>
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type vendor's name")}>
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
@@ -109,7 +109,7 @@ export function CreateVendorDialog({
{searchQuery.trim().length >= 2 && (
<ComboboxItem onClick={() => void onSelect(searchQuery.trim())}>
<IconPlusLarge size={20} />
{__("Create a new vendor")}
{__("Create a new third party")}
{" "}
:
{searchQuery}

View File

@@ -28,24 +28,24 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const deleteBusinessAssociateAgreementMutation = graphql`
mutation DeleteBusinessAssociateAgreementDialogMutation(
$input: DeleteVendorBusinessAssociateAgreementInput!
$input: DeleteThirdPartyBusinessAssociateAgreementInput!
) {
deleteVendorBusinessAssociateAgreement(input: $input) {
deletedVendorId
deleteThirdPartyBusinessAssociateAgreement(input: $input) {
deletedThirdPartyId
}
}
`;
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
fileName: string;
onSuccess?: () => void;
};
export function DeleteBusinessAssociateAgreementDialog({
children,
vendorId,
thirdPartyId,
fileName,
onSuccess,
}: Props) {
@@ -61,7 +61,7 @@ export function DeleteBusinessAssociateAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
},
},
});

View File

@@ -28,24 +28,24 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const deleteDataPrivacyAgreementMutation = graphql`
mutation DeleteDataPrivacyAgreementDialogMutation(
$input: DeleteVendorDataPrivacyAgreementInput!
$input: DeleteThirdPartyDataPrivacyAgreementInput!
) {
deleteVendorDataPrivacyAgreement(input: $input) {
deletedVendorId
deleteThirdPartyDataPrivacyAgreement(input: $input) {
deletedThirdPartyId
}
}
`;
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
fileName: string;
onSuccess?: () => void;
};
export function DeleteDataPrivacyAgreementDialog({
children,
vendorId,
thirdPartyId,
fileName,
onSuccess,
}: Props) {
@@ -61,7 +61,7 @@ export function DeleteDataPrivacyAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
},
},
});

View File

@@ -31,10 +31,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateBusinessAssociateAgreementMutation = graphql`
mutation EditBusinessAssociateAgreementDialogMutation(
$input: UpdateVendorBusinessAssociateAgreementInput!
$input: UpdateThirdPartyBusinessAssociateAgreementInput!
) {
updateVendorBusinessAssociateAgreement(input: $input) {
vendorBusinessAssociateAgreement {
updateThirdPartyBusinessAssociateAgreement(input: $input) {
thirdPartyBusinessAssociateAgreement {
id
fileUrl
validFrom
@@ -52,7 +52,7 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
agreement: {
validFrom?: string | null;
validUntil?: string | null;
@@ -62,7 +62,7 @@ type Props = {
export function EditBusinessAssociateAgreementDialog({
children,
vendorId,
thirdPartyId,
agreement,
onSuccess,
}: Props) {
@@ -100,7 +100,7 @@ export function EditBusinessAssociateAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),
},

View File

@@ -27,21 +27,21 @@ import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { VendorContactsTabFragment_contact$data } from "#/__generated__/core/VendorContactsTabFragment_contact.graphql";
import type { ThirdPartyContactsTabFragment_contact$data } from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
contactId: string;
contact: VendorContactsTabFragment_contact$data;
contact: ThirdPartyContactsTabFragment_contact$data;
onClose: () => void;
};
const updateContactMutation = graphql`
mutation EditContactDialogUpdateMutation($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) {
vendorContact {
...VendorContactsTabFragment_contact
mutation EditContactDialogUpdateMutation($input: UpdateThirdPartyContactInput!) {
updateThirdPartyContact(input: $input) {
thirdPartyContact {
...ThirdPartyContactsTabFragment_contact
}
}
}

View File

@@ -31,10 +31,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateDataPrivacyAgreementMutation = graphql`
mutation EditDataPrivacyAgreementDialogMutation(
$input: UpdateVendorDataPrivacyAgreementInput!
$input: UpdateThirdPartyDataPrivacyAgreementInput!
) {
updateVendorDataPrivacyAgreement(input: $input) {
vendorDataPrivacyAgreement {
updateThirdPartyDataPrivacyAgreement(input: $input) {
thirdPartyDataPrivacyAgreement {
id
fileUrl
validFrom
@@ -52,7 +52,7 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
agreement: {
validFrom?: string | null;
validUntil?: string | null;
@@ -62,7 +62,7 @@ type Props = {
export function EditDataPrivacyAgreementDialog({
children,
vendorId,
thirdPartyId,
agreement,
onSuccess,
}: Props) {
@@ -100,7 +100,7 @@ export function EditDataPrivacyAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),
},

View File

@@ -40,10 +40,10 @@ type Props = {
};
const updateServiceMutation = graphql`
mutation EditServiceDialogUpdateMutation($input: UpdateVendorServiceInput!) {
updateVendorService(input: $input) {
vendorService {
...VendorServicesTabFragment_service
mutation EditServiceDialogUpdateMutation($input: UpdateThirdPartyServiceInput!) {
updateThirdPartyService(input: $input) {
thirdPartyService {
...ThirdPartyServicesTabFragment_service
}
}
}

View File

@@ -33,26 +33,26 @@ const schema = z.object({
});
const importAssessmentMutation = graphql`
mutation ImportAssessmentDialogMutation($input: AssessVendorInput!) {
assessVendor(input: $input) {
vendor {
mutation ImportAssessmentDialogMutation($input: AssessThirdPartyInput!) {
assessThirdParty(input: $input) {
thirdParty {
id
name
websiteUrl
...useVendorFormFragment
...VendorComplianceTabFragment
...VendorRiskAssessmentTabFragment
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyRiskAssessmentTabFragment
}
}
}
`;
type Props = {
vendorId: string;
thirdPartyId: string;
children: ReactNode;
};
export function ImportAssessmentDialog({ vendorId, children }: Props) {
export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const { register, handleSubmit, reset, formState } = useFormWithSchema(
@@ -66,8 +66,8 @@ export function ImportAssessmentDialog({ vendorId, children }: Props) {
const [assess, isAssessing] = useMutationWithToasts(
importAssessmentMutation,
{
successMessage: __("Vendor assessed successfully."),
errorMessage: __("Failed to assess vendor"),
successMessage: __("Third party assessed successfully."),
errorMessage: __("Failed to assess third party"),
},
);
@@ -75,7 +75,7 @@ export function ImportAssessmentDialog({ vendorId, children }: Props) {
await assess({
variables: {
input: {
id: vendorId,
id: thirdPartyId,
websiteUrl: data.url,
},
},

View File

@@ -30,15 +30,15 @@ import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishVendorListDialogMutation } from "#/__generated__/core/PublishVendorListDialogMutation.graphql";
import type { PublishThirdPartyListDialogMutation } from "#/__generated__/core/PublishThirdPartyListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishVendorListDialogMutation(
$input: PublishVendorListInput!
mutation PublishThirdPartyListDialogMutation(
$input: PublishThirdPartyListInput!
) {
publishVendorList(input: $input) {
publishThirdPartyList(input: $input) {
documentEdge {
node {
id
@@ -55,7 +55,7 @@ type Props = {
onPublished?: (documentId: string) => void;
};
export function PublishVendorListDialog({
export function PublishThirdPartyListDialog({
children,
organizationId,
defaultApproverIds,
@@ -82,7 +82,7 @@ export function PublishVendorListDialog({
});
const [publish, isPublishing]
= useMutation<PublishVendorListDialogMutation>(publishMutation);
= useMutation<PublishThirdPartyListDialogMutation>(publishMutation);
const minorRef = useRef(false);
@@ -99,13 +99,13 @@ export function PublishVendorListDialog({
},
},
onCompleted(response) {
const documentId = response.publishVendorList?.documentEdge?.node?.id;
const documentId = response.publishThirdPartyList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Vendors published successfully."),
: __("Third parties published successfully."),
variant: "success",
});
dialogRef.current?.close();
@@ -117,7 +117,7 @@ export function PublishVendorListDialog({
toast({
title: __("Error"),
description: formatError(
__("Failed to publish vendors"),
__("Failed to publish third parties"),
error as GraphQLError,
),
variant: "error",
@@ -131,7 +131,7 @@ export function PublishVendorListDialog({
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Vendors")}
title={__("Publish third parties")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>

View File

@@ -33,10 +33,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadBusinessAssociateAgreementMutation = graphql`
mutation UploadBusinessAssociateAgreementDialogMutation(
$input: UploadVendorBusinessAssociateAgreementInput!
$input: UploadThirdPartyBusinessAssociateAgreementInput!
) {
uploadVendorBusinessAssociateAgreement(input: $input) {
vendorBusinessAssociateAgreement {
uploadThirdPartyBusinessAssociateAgreement(input: $input) {
thirdPartyBusinessAssociateAgreement {
id
fileName
fileUrl
@@ -56,13 +56,13 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
onSuccess?: () => void;
};
export function UploadBusinessAssociateAgreementDialog({
children,
vendorId,
thirdPartyId,
onSuccess,
}: Props) {
const { __ } = useTranslate();
@@ -109,7 +109,7 @@ export function UploadBusinessAssociateAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
fileName: data.fileName,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),

View File

@@ -34,11 +34,11 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadComplianceReportMutation = graphql`
mutation UploadComplianceReportDialogMutation(
$input: UploadVendorComplianceReportInput!
$input: UploadThirdPartyComplianceReportInput!
$connections: [ID!]!
) {
uploadVendorComplianceReport(input: $input) {
vendorComplianceReportEdge @appendEdge(connections: $connections) {
uploadThirdPartyComplianceReport(input: $input) {
thirdPartyComplianceReportEdge @appendEdge(connections: $connections) {
node {
id
reportName
@@ -50,7 +50,7 @@ const uploadComplianceReportMutation = graphql`
size
downloadUrl
}
canDelete: permission(action: "core:vendor-compliance-report:delete")
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
}
}
}
@@ -64,14 +64,14 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
connectionId: string;
onSuccess?: () => void;
};
export function UploadComplianceReportDialog({
children,
vendorId,
thirdPartyId,
connectionId,
onSuccess,
}: Props) {
@@ -111,7 +111,7 @@ export function UploadComplianceReportDialog({
variables: {
connections: [connectionId],
input: {
vendorId,
thirdPartyId,
reportName: uploadedFile.name,
reportDate: `${data.reportDate}T00:00:00Z`,
validUntil: formatDatetime(data.validUntil),

View File

@@ -33,10 +33,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadDataPrivacyAgreementMutation = graphql`
mutation UploadDataPrivacyAgreementDialogMutation(
$input: UploadVendorDataPrivacyAgreementInput!
$input: UploadThirdPartyDataPrivacyAgreementInput!
) {
uploadVendorDataPrivacyAgreement(input: $input) {
vendorDataPrivacyAgreement {
uploadThirdPartyDataPrivacyAgreement(input: $input) {
thirdPartyDataPrivacyAgreement {
id
fileName
fileUrl
@@ -56,13 +56,13 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
onSuccess?: () => void;
};
export function UploadDataPrivacyAgreementDialog({
children,
vendorId,
thirdPartyId,
onSuccess,
}: Props) {
const { __ } = useTranslate();
@@ -109,7 +109,7 @@ export function UploadDataPrivacyAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
fileName: data.fileName,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),

View File

@@ -32,23 +32,23 @@ import { useState } from "react";
import { Controller } from "react-hook-form";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { useVendorForm } from "#/hooks/forms/useVendorForm";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
/**
* Vendor certifications tab
* ThirdParty certifications tab
*/
export default function VendorCertificationsTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyCertificationsTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { control, handleSubmit } = useVendorForm(vendor);
const { control, handleSubmit } = useThirdPartyForm(thirdParty);
return (
<form
className="space-y-4"
onSubmit={vendor.canUpdate
onSubmit={thirdParty.canUpdate
? e => void handleSubmit(e)
: undefined}
>
@@ -60,14 +60,14 @@ export default function VendorCertificationsTab() {
<Certifications
onValueChange={field.onChange}
value={field.value ?? []}
readOnly={!vendor.canUpdate}
readOnly={!thirdParty.canUpdate}
/>
)}
/>
</Card>
{vendor.canUpdate && (
{thirdParty.canUpdate && (
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
<Button type="submit">{__("Update third party")}</Button>
</div>
)}
</form>

View File

@@ -36,20 +36,20 @@ import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorComplianceTabFragment_report$key } from "#/__generated__/core/VendorComplianceTabFragment_report.graphql";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.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 VendorComplianceTabFragment on Vendor
fragment ThirdPartyComplianceTabFragment on ThirdParty
@refetchable(queryName: "ComplianceReportListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorComplianceReportOrder", defaultValue: null }
order: { type: "ThirdPartyComplianceReportOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -60,13 +60,13 @@ export const complianceReportsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorComplianceTabFragment_complianceReports") {
) @connection(key: "ThirdPartyComplianceTabFragment_complianceReports") {
__id
edges {
node {
id
canDelete: permission(action: "core:vendor-compliance-report:delete")
...VendorComplianceTabFragment_report
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
...ThirdPartyComplianceTabFragment_report
}
}
}
@@ -74,7 +74,7 @@ export const complianceReportsFragment = graphql`
`;
const complianceReportFragment = graphql`
fragment VendorComplianceTabFragment_report on VendorComplianceReport {
fragment ThirdPartyComplianceTabFragment_report on ThirdPartyComplianceReport {
id
reportDate
validUntil
@@ -84,43 +84,43 @@ const complianceReportFragment = graphql`
size
downloadUrl
}
canDelete: permission(action: "core:vendor-compliance-report:delete")
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
}
`;
const deleteReportMutation = graphql`
mutation VendorComplianceTabDeleteReportMutation(
$input: DeleteVendorComplianceReportInput!
mutation ThirdPartyComplianceTabDeleteReportMutation(
$input: DeleteThirdPartyComplianceReportInput!
$connections: [ID!]!
) {
deleteVendorComplianceReport(input: $input) {
deletedVendorComplianceReportId @deleteEdge(connections: $connections)
deleteThirdPartyComplianceReport(input: $input) {
deletedThirdPartyComplianceReportId @deleteEdge(connections: $connections)
}
}
`;
export default function VendorComplianceTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyComplianceTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
ComplianceReportListQuery,
VendorComplianceTabFragment$key
>(complianceReportsFragment, vendor);
ThirdPartyComplianceTabFragment$key
>(complianceReportsFragment, thirdParty);
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map(edge => edge.node);
const { __ } = useTranslate();
usePageTitle(vendor.name + " - " + __("Compliance reports"));
usePageTitle(thirdParty.name + " - " + __("Compliance reports"));
return (
<div className="space-y-6">
<PageHeader
title={__("Compliance reports")}
description={__("Track vendor compliance certifications and reports.")}
description={__("Track third party compliance certifications and reports.")}
>
{vendor.canUploadComplianceReport && (
{thirdParty.canUploadComplianceReport && (
<UploadComplianceReportDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add report")}</Button>
@@ -155,13 +155,13 @@ export default function VendorComplianceTab() {
}
type ReportRowProps = {
reportKey: VendorComplianceTabFragment_report$key;
reportKey: ThirdPartyComplianceTabFragment_report$key;
connectionId: string;
};
function ReportRow(props: ReportRowProps) {
const { __ } = useTranslate();
const report = useFragment<VendorComplianceTabFragment_report$key>(
const report = useFragment<ThirdPartyComplianceTabFragment_report$key>(
complianceReportFragment,
props.reportKey,
);

View File

@@ -35,25 +35,25 @@ import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorContactsListQuery } from "#/__generated__/core/VendorContactsListQuery.graphql";
import type { VendorContactsTabFragment$key } from "#/__generated__/core/VendorContactsTabFragment.graphql";
import type { ThirdPartyContactsListQuery } from "#/__generated__/core/ThirdPartyContactsListQuery.graphql";
import type { ThirdPartyContactsTabFragment$key } from "#/__generated__/core/ThirdPartyContactsTabFragment.graphql";
import type {
VendorContactsTabFragment_contact$data,
VendorContactsTabFragment_contact$key,
} from "#/__generated__/core/VendorContactsTabFragment_contact.graphql";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
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 vendorContactsFragment = graphql`
fragment VendorContactsTabFragment on Vendor
@refetchable(queryName: "VendorContactsListQuery")
export const thirdPartyContactsFragment = graphql`
fragment ThirdPartyContactsTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyContactsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorContactOrder", defaultValue: null }
order: { type: "ThirdPartyContactOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -64,14 +64,14 @@ export const vendorContactsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorContactsTabFragment_contacts") {
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
__id
edges {
node {
id
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
...VendorContactsTabFragment_contact
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactsTabFragment_contact
}
}
}
@@ -79,55 +79,55 @@ export const vendorContactsFragment = graphql`
`;
const contactFragment = graphql`
fragment VendorContactsTabFragment_contact on VendorContact {
fragment ThirdPartyContactsTabFragment_contact on ThirdPartyContact {
id
fullName
email
phone
role
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
}
`;
const deleteContactMutation = graphql`
mutation VendorContactsTabDeleteContactMutation(
$input: DeleteVendorContactInput!
mutation ThirdPartyContactsTabDeleteContactMutation(
$input: DeleteThirdPartyContactInput!
$connections: [ID!]!
) {
deleteVendorContact(input: $input) {
deletedVendorContactId @deleteEdge(connections: $connections)
deleteThirdPartyContact(input: $input) {
deletedThirdPartyContactId @deleteEdge(connections: $connections)
}
}
`;
export default function VendorContactsTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyContactsTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
VendorContactsListQuery,
VendorContactsTabFragment$key
>(vendorContactsFragment, vendor);
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<VendorContactsTabFragment_contact$data | null>(null);
= useState<ThirdPartyContactsTabFragment_contact$data | null>(null);
const hasAnyAction = contacts.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(vendor.name + " - " + __("Contacts"));
usePageTitle(thirdParty.name + " - " + __("Contacts"));
return (
<div className="space-y-6">
<PageHeader
title={__("Contacts")}
description={__("Manage vendor contacts and their information.")}
description={__("Manage third party contacts and their information.")}
>
{vendor.canCreateContact && (
<CreateContactDialog vendorId={vendor.id} connectionId={connectionId}>
{thirdParty.canCreateContact && (
<CreateContactDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
)}
@@ -169,14 +169,14 @@ export default function VendorContactsTab() {
}
type ContactRowProps = {
contactKey: VendorContactsTabFragment_contact$key;
contactKey: ThirdPartyContactsTabFragment_contact$key;
connectionId: string;
onEdit: (contact: VendorContactsTabFragment_contact$data) => void;
onEdit: (contact: ThirdPartyContactsTabFragment_contact$data) => void;
};
function ContactRow(props: ContactRowProps) {
const { __ } = useTranslate();
const contact = useFragment<VendorContactsTabFragment_contact$key>(
const contact = useFragment<ThirdPartyContactsTabFragment_contact$key>(
contactFragment,
props.contactKey,
);
@@ -194,7 +194,7 @@ function ContactRow(props: ContactRowProps) {
variables: {
connections: [props.connectionId],
input: {
vendorContactId: contact.id,
thirdPartyContactId: contact.id,
},
},
}),

View File

@@ -15,6 +15,7 @@
import { downloadFile, formatDate } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import type { ThirdPartyCategory } from "@probo/third-parties";
import {
Button,
Card,
@@ -25,18 +26,17 @@ import {
Input,
Option,
} from "@probo/ui";
import type { VendorCategory } from "@probo/vendors";
import { useMemo } from "react";
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
import type { VendorOverviewTabDataPrivacyAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabDataPrivacyAgreementFragment.graphql";
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 { ControlledField } from "#/components/form/ControlledField";
import { CountriesField } from "#/components/form/CountriesField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { useVendorForm } from "#/hooks/forms/useVendorForm";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeleteBusinessAssociateAgreementDialog } from "../dialogs/DeleteBusinessAssociateAgreementDialog";
@@ -46,8 +46,8 @@ import { EditDataPrivacyAgreementDialog } from "../dialogs/EditDataPrivacyAgreem
import { UploadBusinessAssociateAgreementDialog } from "../dialogs/UploadBusinessAssociateAgreementDialog";
import { UploadDataPrivacyAgreementDialog } from "../dialogs/UploadDataPrivacyAgreementDialog";
const vendorBusinessAssociateAgreementFragment = graphql`
fragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {
const thirdPartyBusinessAssociateAgreementFragment = graphql`
fragment ThirdPartyOverviewTabBusinessAssociateAgreementFragment on ThirdParty {
businessAssociateAgreement {
id
fileName
@@ -55,36 +55,36 @@ const vendorBusinessAssociateAgreementFragment = graphql`
validFrom
validUntil
canUpdate: permission(
action: "core:vendor-business-associate-agreement:update"
action: "core:thirdParty-business-associate-agreement:update"
)
canDelete: permission(
action: "core:vendor-business-associate-agreement:delete"
action: "core:thirdParty-business-associate-agreement:delete"
)
}
}
`;
const vendorDataPrivacyAgreementFragment = graphql`
fragment VendorOverviewTabDataPrivacyAgreementFragment on Vendor {
const thirdPartyDataPrivacyAgreementFragment = graphql`
fragment ThirdPartyOverviewTabDataPrivacyAgreementFragment on ThirdParty {
dataPrivacyAgreement {
id
fileName
fileUrl
validFrom
validUntil
canUpdate: permission(action: "core:vendor-data-privacy-agreement:update")
canDelete: permission(action: "core:vendor-data-privacy-agreement:delete")
canUpdate: permission(action: "core:thirdParty-data-privacy-agreement:update")
canDelete: permission(action: "core:thirdParty-data-privacy-agreement:delete")
}
}
`;
export default function VendorOverviewTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyOverviewTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const vendorCategories: { value: VendorCategory; label: string }[] = [
const thirdPartyCategories: { value: ThirdPartyCategory; label: string }[] = [
{ value: "ANALYTICS", label: __("Analytics") },
{ value: "CLOUD_MONITORING", label: __("Cloud Monitoring") },
{ value: "CLOUD_PROVIDER", label: __("Cloud Provider") },
@@ -118,21 +118,21 @@ export default function VendorOverviewTab() {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useVendorForm(vendor);
} = useThirdPartyForm(thirdParty);
const vendorWithBAA
= useFragment<VendorOverviewTabBusinessAssociateAgreementFragment$key>(
vendorBusinessAssociateAgreementFragment,
vendor,
const thirdPartyWithBAA
= useFragment<ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key>(
thirdPartyBusinessAssociateAgreementFragment,
thirdParty,
);
const businessAssociateAgreement = vendorWithBAA.businessAssociateAgreement;
const businessAssociateAgreement = thirdPartyWithBAA.businessAssociateAgreement;
const vendorWithDPA
= useFragment<VendorOverviewTabDataPrivacyAgreementFragment$key>(
vendorDataPrivacyAgreementFragment,
vendor,
const thirdPartyWithDPA
= useFragment<ThirdPartyOverviewTabDataPrivacyAgreementFragment$key>(
thirdPartyDataPrivacyAgreementFragment,
thirdParty,
);
const dataPrivacyAgreement = vendorWithDPA.dataPrivacyAgreement;
const dataPrivacyAgreement = thirdPartyWithDPA.dataPrivacyAgreement;
const urls = useMemo(
() =>
@@ -154,20 +154,20 @@ export default function VendorOverviewTab() {
[__],
);
usePageTitle(vendor.name + " - " + __("Overview"));
usePageTitle(thirdParty.name + " - " + __("Overview"));
const isFormDisabled = isSubmitting || !vendor.canUpdate;
const isFormDisabled = isSubmitting || !thirdParty.canUpdate;
return (
<form
onSubmit={!vendor.canUpdate
onSubmit={!thirdParty.canUpdate
? undefined
: e => void handleSubmit(e)}
className="space-y-12"
>
{/* Vendor Details */}
{/* ThirdParty Details */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Vendor details")}</h2>
<h2 className="text-base font-medium">{__("Third party details")}</h2>
<Card className="space-y-4" padded>
<Field
{...register("name")}
@@ -192,7 +192,7 @@ export default function VendorOverviewTab() {
error={errors.category?.message}
disabled={isFormDisabled}
>
{vendorCategories.map(category => (
{thirdPartyCategories.map(category => (
<Option key={category.value} value={category.value}>
{category.label}
</Option>
@@ -330,7 +330,7 @@ export default function VendorOverviewTab() {
</Button>
{businessAssociateAgreement.canUpdate && (
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
agreement={{
validFrom: businessAssociateAgreement.validFrom,
validUntil: businessAssociateAgreement.validUntil,
@@ -342,7 +342,7 @@ export default function VendorOverviewTab() {
)}
{businessAssociateAgreement.canDelete && (
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
fileName={businessAssociateAgreement.fileName}
onSuccess={() => window.location.reload()}
>
@@ -352,9 +352,9 @@ export default function VendorOverviewTab() {
</>
)
: (
vendor.canUploadBAA && (
thirdParty.canUploadBAA && (
<UploadBusinessAssociateAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
onSuccess={() => window.location.reload()}
>
<Button variant="secondary" icon={IconPlusLarge}>
@@ -404,7 +404,7 @@ export default function VendorOverviewTab() {
</Button>
{dataPrivacyAgreement.canUpdate && (
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
agreement={{
validFrom: dataPrivacyAgreement.validFrom,
validUntil: dataPrivacyAgreement.validUntil,
@@ -416,7 +416,7 @@ export default function VendorOverviewTab() {
)}
{dataPrivacyAgreement.canDelete && (
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
fileName={dataPrivacyAgreement.fileName}
onSuccess={() => window.location.reload()}
>
@@ -426,9 +426,9 @@ export default function VendorOverviewTab() {
</>
)
: (
vendor.canUploadDPA && (
thirdParty.canUploadDPA && (
<UploadDataPrivacyAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
onSuccess={() => window.location.reload()}
>
<Button variant="secondary" icon={IconPlusLarge}>
@@ -444,9 +444,9 @@ export default function VendorOverviewTab() {
{/* Submit */}
<div className="flex justify-end">
{vendor.canUpdate && (
{thirdParty.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
{__("Update third party")}
</Button>
)}
</div>

View File

@@ -33,20 +33,20 @@ import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorRiskAssessmentTabFragment$key } from "#/__generated__/core/VendorRiskAssessmentTabFragment.graphql";
import type { VendorRiskAssessmentTabFragment_assessment$key } from "#/__generated__/core/VendorRiskAssessmentTabFragment_assessment.graphql";
import type { VendorRiskAssessmentTabQuery } from "#/__generated__/core/VendorRiskAssessmentTabQuery.graphql";
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 VendorRiskAssessmentTabFragment on Vendor
@refetchable(queryName: "VendorRiskAssessmentTabQuery")
fragment ThirdPartyRiskAssessmentTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyRiskAssessmentTabQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorRiskAssessmentOrder", defaultValue: null }
order: { type: "ThirdPartyRiskAssessmentOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -59,12 +59,12 @@ const riskAssessmentsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorRiskAssessmentTabFragment_riskAssessments") {
) @connection(key: "ThirdPartyRiskAssessmentTabFragment_riskAssessments") {
__id
edges {
node {
id
...VendorRiskAssessmentTabFragment_assessment
...ThirdPartyRiskAssessmentTabFragment_assessment
}
}
pageInfo {
@@ -76,7 +76,7 @@ const riskAssessmentsFragment = graphql`
`;
const riskAssessmentFragment = graphql`
fragment VendorRiskAssessmentTabFragment_assessment on VendorRiskAssessment {
fragment ThirdPartyRiskAssessmentTabFragment_assessment on ThirdPartyRiskAssessment {
id
createdAt
expiresAt
@@ -86,27 +86,27 @@ const riskAssessmentFragment = graphql`
}
`;
export default function VendorRiskAssessmentTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyRiskAssessmentTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
VendorRiskAssessmentTabQuery,
VendorRiskAssessmentTabFragment$key
>(riskAssessmentsFragment, vendor);
ThirdPartyRiskAssessmentTabQuery,
ThirdPartyRiskAssessmentTabFragment$key
>(riskAssessmentsFragment, thirdParty);
const assessments = data.riskAssessments.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [expanded, setExpanded] = useState<string | null>(null);
usePageTitle(vendor.name + " - " + __("Risk Assessments"));
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")}
{vendor.canCreateRiskAssessment && (
{thirdParty.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
connection={data.riskAssessments.__id}
>
<Button icon={IconPlusLarge} variant="secondary">
@@ -134,9 +134,9 @@ export default function VendorRiskAssessmentTab() {
</Tr>
</Thead>
<Tbody>
{vendor.canCreateRiskAssessment && (
{thirdParty.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
connection={data.riskAssessments.__id}
>
<TrButton colspan={5} onClick={() => {}}>
@@ -163,7 +163,7 @@ export default function VendorRiskAssessmentTab() {
}
type AssessmentRowProps = {
assessmentKey: VendorRiskAssessmentTabFragment_assessment$key;
assessmentKey: ThirdPartyRiskAssessmentTabFragment_assessment$key;
onClick: (id: string) => void;
isExpanded: boolean;
};
@@ -171,7 +171,7 @@ type AssessmentRowProps = {
function AssessmentRow(props: AssessmentRowProps) {
const { __ } = useTranslate();
const assessment
= useFragment<VendorRiskAssessmentTabFragment_assessment$key>(
= useFragment<ThirdPartyRiskAssessmentTabFragment_assessment$key>(
riskAssessmentFragment,
props.assessmentKey,
);

View File

@@ -35,25 +35,25 @@ import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorServicesListQuery } from "#/__generated__/core/VendorServicesListQuery.graphql";
import type { VendorServicesTabFragment$key } from "#/__generated__/core/VendorServicesTabFragment.graphql";
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 {
VendorServicesTabFragment_service$data,
VendorServicesTabFragment_service$key,
} from "#/__generated__/core/VendorServicesTabFragment_service.graphql";
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 vendorServicesFragment = graphql`
fragment VendorServicesTabFragment on Vendor
@refetchable(queryName: "VendorServicesListQuery")
export const thirdPartyServicesFragment = graphql`
fragment ThirdPartyServicesTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyServicesListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorServiceOrder", defaultValue: null }
order: { type: "ThirdPartyServiceOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -64,14 +64,14 @@ export const vendorServicesFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorServicesTabFragment_services") {
) @connection(key: "ThirdPartyServicesTabFragment_services") {
__id
edges {
node {
id
canUpdate: permission(action: "core:vendor-service:update")
canDelete: permission(action: "core:vendor-service:delete")
...VendorServicesTabFragment_service
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
...ThirdPartyServicesTabFragment_service
}
}
}
@@ -79,53 +79,53 @@ export const vendorServicesFragment = graphql`
`;
const serviceFragment = graphql`
fragment VendorServicesTabFragment_service on VendorService {
fragment ThirdPartyServicesTabFragment_service on ThirdPartyService {
id
name
description
canUpdate: permission(action: "core:vendor-service:update")
canDelete: permission(action: "core:vendor-service:delete")
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
}
`;
const deleteServiceMutation = graphql`
mutation VendorServicesTabDeleteServiceMutation(
$input: DeleteVendorServiceInput!
mutation ThirdPartyServicesTabDeleteServiceMutation(
$input: DeleteThirdPartyServiceInput!
$connections: [ID!]!
) {
deleteVendorService(input: $input) {
deletedVendorServiceId @deleteEdge(connections: $connections)
deleteThirdPartyService(input: $input) {
deletedThirdPartyServiceId @deleteEdge(connections: $connections)
}
}
`;
export default function VendorServicesTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyServicesTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
VendorServicesListQuery,
VendorServicesTabFragment$key
>(vendorServicesFragment, vendor);
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<VendorServicesTabFragment_service$data | null>(null);
= useState<ThirdPartyServicesTabFragment_service$data | null>(null);
const hasAnyAction = services.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(vendor.name + " - " + __("Services"));
usePageTitle(thirdParty.name + " - " + __("Services"));
return (
<div className="space-y-6">
<PageHeader
title={__("Services")}
description={__("Manage services provided by this vendor.")}
description={__("Manage services provided by this third party.")}
>
{vendor.canCreateService && (
<CreateServiceDialog vendorId={vendor.id} connectionId={connectionId}>
{thirdParty.canCreateService && (
<CreateServiceDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
)}
@@ -165,14 +165,14 @@ export default function VendorServicesTab() {
}
type ServiceRowProps = {
serviceKey: VendorServicesTabFragment_service$key;
serviceKey: ThirdPartyServicesTabFragment_service$key;
connectionId: string;
onEdit: (service: VendorServicesTabFragment_service$data) => void;
onEdit: (service: ThirdPartyServicesTabFragment_service$data) => void;
};
function ServiceRow(props: ServiceRowProps) {
const { __ } = useTranslate();
const service = useFragment<VendorServicesTabFragment_service$key>(
const service = useFragment<ThirdPartyServicesTabFragment_service$key>(
serviceFragment,
props.serviceKey,
);
@@ -190,7 +190,7 @@ function ServiceRow(props: ServiceRowProps) {
variables: {
connections: [props.connectionId],
input: {
vendorServiceId: service.id,
thirdPartyServiceId: service.id,
},
},
}),

View File

@@ -47,7 +47,7 @@ import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
import { riskRoutes } from "./routes/riskRoutes";
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
import { taskRoutes } from "./routes/taskRoutes";
import { vendorRoutes } from "./routes/vendorRoutes";
import { thirdPartyRoutes } from "./routes/thirdPartyRoutes";
const routes = [
{
@@ -291,7 +291,7 @@ const routes = [
...riskRoutes,
...measureRoutes,
...documentsRoutes,
...vendorRoutes,
...thirdPartyRoutes,
...frameworkRoutes,
...taskRoutes,
...assetRoutes,

View File

@@ -20,43 +20,43 @@ import {
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
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 { vendorNodeQuery, vendorsQuery } from "#/hooks/graph/VendorGraph";
import { thirdPartiesQuery, thirdPartyNodeQuery } from "#/hooks/graph/ThirdPartyGraph";
export const vendorRoutes = [
export const thirdPartyRoutes = [
{
path: "vendors",
path: "third-parties",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
loadQuery<ThirdPartyGraphListQuery>(coreEnvironment, thirdPartiesQuery, {
organizationId: organizationId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
lazy(() => import("#/pages/organizations/third-parties/ThirdPartiesPage")),
),
},
{
path: "vendors/:vendorId",
path: "third-parties/:thirdPartyId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ vendorId }) =>
loadQuery<VendorGraphNodeQuery>(coreEnvironment, vendorNodeQuery, {
vendorId: vendorId,
loader: loaderFromQueryLoader(({ thirdPartyId }) =>
loadQuery<ThirdPartyGraphNodeQuery>(coreEnvironment, thirdPartyNodeQuery, {
thirdPartyId: thirdPartyId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/vendors/VendorDetailPage")),
lazy(() => import("../pages/organizations/third-parties/ThirdPartyDetailPage")),
),
children: [
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorOverviewTab"),
() => import("../pages/organizations/third-parties/tabs/ThirdPartyOverviewTab"),
),
},
{
@@ -64,7 +64,7 @@ export const vendorRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorCertificationsTab"),
import("../pages/organizations/third-parties/tabs/ThirdPartyCertificationsTab"),
),
},
{
@@ -72,7 +72,7 @@ export const vendorRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorComplianceTab"),
import("../pages/organizations/third-parties/tabs/ThirdPartyComplianceTab"),
),
},
{
@@ -80,21 +80,21 @@ export const vendorRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorRiskAssessmentTab"),
import("../pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab"),
),
},
{
path: "contacts",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorContactsTab"),
() => import("../pages/organizations/third-parties/tabs/ThirdPartyContactsTab"),
),
},
{
path: "services",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorServicesTab"),
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
),
},
],