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:
@@ -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",
|
||||
|
||||
@@ -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) ?? []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
@@ -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,
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
246
apps/console/src/hooks/graph/ThirdPartyGraph.ts
Normal file
246
apps/console/src/hooks/graph/ThirdPartyGraph.ts
Normal 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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
@@ -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],
|
||||
@@ -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],
|
||||
@@ -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],
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
@@ -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),
|
||||
@@ -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),
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
},
|
||||
],
|
||||
Reference in New Issue
Block a user