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"),
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -13,7 +13,7 @@
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Command common-third-parties-import seeds the common_third_parties table from
|
||||
// packages/vendors/data.json. It is idempotent: re-running upserts on conflict
|
||||
// packages/thirdParties/data.json. It is idempotent: re-running upserts on conflict
|
||||
// (lower(name)) so existing rows keep their id and created_at.
|
||||
//
|
||||
// When -fetch-logos is set, the tool inspects each third party's website to
|
||||
@@ -419,15 +419,15 @@ func loadThirdParties(path string) ([]thirdPartyData, error) {
|
||||
return thirdParties, nil
|
||||
}
|
||||
|
||||
func parseCategory(tp thirdPartyData) coredata.VendorCategory {
|
||||
func parseCategory(tp thirdPartyData) coredata.ThirdPartyCategory {
|
||||
if tp.Category == nil || *tp.Category == "" {
|
||||
return coredata.VendorCategoryOther
|
||||
return coredata.ThirdPartyCategoryOther
|
||||
}
|
||||
|
||||
var c coredata.VendorCategory
|
||||
var c coredata.ThirdPartyCategory
|
||||
if err := c.Scan(*tp.Category); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: third party %q has unknown category %q, falling back to OTHER\n", tp.Name, *tp.Category)
|
||||
return coredata.VendorCategoryOther
|
||||
return coredata.ThirdPartyCategoryOther
|
||||
}
|
||||
|
||||
return c
|
||||
|
||||
@@ -339,41 +339,41 @@ ORDER BY a.name ASC;
|
||||
return "", err
|
||||
}
|
||||
|
||||
vendorRows, err := tx.Query(
|
||||
thirdPartyRows, err := tx.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
av.asset_id,
|
||||
v.name
|
||||
FROM asset_vendors av
|
||||
JOIN vendors v ON v.id = av.vendor_id
|
||||
FROM asset_third_parties av
|
||||
JOIN third_parties v ON v.id = av.third_party_id
|
||||
WHERE av.snapshot_id = @snapshot_id
|
||||
ORDER BY v.name ASC;
|
||||
`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot load snapshot asset vendors: %w", err)
|
||||
return "", fmt.Errorf("cannot load snapshot asset thirdParties: %w", err)
|
||||
}
|
||||
defer vendorRows.Close()
|
||||
defer thirdPartyRows.Close()
|
||||
|
||||
vendorsByAsset := make(map[string][]string)
|
||||
for vendorRows.Next() {
|
||||
var assetID, vendorName string
|
||||
if err := vendorRows.Scan(&assetID, &vendorName); err != nil {
|
||||
return "", fmt.Errorf("cannot scan vendor: %w", err)
|
||||
thirdPartiesByAsset := make(map[string][]string)
|
||||
for thirdPartyRows.Next() {
|
||||
var assetID, thirdPartyName string
|
||||
if err := thirdPartyRows.Scan(&assetID, &thirdPartyName); err != nil {
|
||||
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
|
||||
}
|
||||
vendorsByAsset[assetID] = append(vendorsByAsset[assetID], vendorName)
|
||||
thirdPartiesByAsset[assetID] = append(thirdPartiesByAsset[assetID], thirdPartyName)
|
||||
}
|
||||
if err := vendorRows.Err(); err != nil {
|
||||
if err := thirdPartyRows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
assetRows := make([]docgen.AssetListRow, len(assets))
|
||||
for i, a := range assets {
|
||||
vendors := "-"
|
||||
if v, ok := vendorsByAsset[a.id]; ok && len(v) > 0 {
|
||||
vendors = strings.Join(v, ", ")
|
||||
thirdParties := "-"
|
||||
if v, ok := thirdPartiesByAsset[a.id]; ok && len(v) > 0 {
|
||||
thirdParties = strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
assetRows[i] = docgen.AssetListRow{
|
||||
@@ -382,7 +382,7 @@ ORDER BY v.name ASC;
|
||||
Amount: a.amount,
|
||||
DataTypesStored: a.dataTypesStored,
|
||||
Owner: a.ownerName,
|
||||
Vendors: vendors,
|
||||
ThirdParties: thirdParties,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,49 +335,49 @@ ORDER BY d.name ASC;
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Load vendors for each datum in this snapshot.
|
||||
vendorRows, err := tx.Query(
|
||||
// Load thirdParties for each datum in this snapshot.
|
||||
thirdPartyRows, err := tx.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
dv.datum_id,
|
||||
v.name
|
||||
FROM data_vendors dv
|
||||
JOIN vendors v ON v.id = dv.vendor_id
|
||||
FROM data_third_parties dv
|
||||
JOIN third_parties v ON v.id = dv.third_party_id
|
||||
WHERE dv.snapshot_id = @snapshot_id
|
||||
ORDER BY v.name ASC;
|
||||
`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot load snapshot data vendors: %w", err)
|
||||
return "", fmt.Errorf("cannot load snapshot data thirdParties: %w", err)
|
||||
}
|
||||
defer vendorRows.Close()
|
||||
defer thirdPartyRows.Close()
|
||||
|
||||
vendorsByDatum := make(map[string][]string)
|
||||
for vendorRows.Next() {
|
||||
var datumID, vendorName string
|
||||
if err := vendorRows.Scan(&datumID, &vendorName); err != nil {
|
||||
return "", fmt.Errorf("cannot scan vendor: %w", err)
|
||||
thirdPartiesByDatum := make(map[string][]string)
|
||||
for thirdPartyRows.Next() {
|
||||
var datumID, thirdPartyName string
|
||||
if err := thirdPartyRows.Scan(&datumID, &thirdPartyName); err != nil {
|
||||
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
|
||||
}
|
||||
vendorsByDatum[datumID] = append(vendorsByDatum[datumID], vendorName)
|
||||
thirdPartiesByDatum[datumID] = append(thirdPartiesByDatum[datumID], thirdPartyName)
|
||||
}
|
||||
if err := vendorRows.Err(); err != nil {
|
||||
if err := thirdPartyRows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dataRows := make([]docgen.DataListRow, len(data))
|
||||
for i, d := range data {
|
||||
vendors := "-"
|
||||
if v, ok := vendorsByDatum[d.id]; ok && len(v) > 0 {
|
||||
vendors = strings.Join(v, ", ")
|
||||
thirdParties := "-"
|
||||
if v, ok := thirdPartiesByDatum[d.id]; ok && len(v) > 0 {
|
||||
thirdParties = strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
dataRows[i] = docgen.DataListRow{
|
||||
Name: d.name,
|
||||
Classification: formatClassificationString(d.classification),
|
||||
Owner: d.ownerName,
|
||||
Vendors: vendors,
|
||||
ThirdParties: thirdParties,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ ORDER BY pa.name ASC;
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
vendorMap, err := loadVendorsForSnapshot(ctx, tx, snapshotID)
|
||||
thirdPartyMap, err := loadThirdPartiesForSnapshot(ctx, tx, snapshotID)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
@@ -415,9 +415,9 @@ ORDER BY pa.name ASC;
|
||||
dpo = p.dpoName
|
||||
}
|
||||
|
||||
vendors := "None"
|
||||
if v, ok := vendorMap[p.id]; ok && len(v) > 0 {
|
||||
vendors = strings.Join(v, ", ")
|
||||
thirdParties := "None"
|
||||
if v, ok := thirdPartyMap[p.id]; ok && len(v) > 0 {
|
||||
thirdParties = strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
listRows[i] = docgen.ProcessingActivityListRow{
|
||||
@@ -440,7 +440,7 @@ ORDER BY pa.name ASC;
|
||||
LastReviewDate: formatDateOrNotSpecified(p.lastReviewDate),
|
||||
NextReviewDate: formatDateOrNotSpecified(p.nextReviewDate),
|
||||
DataProtectionOfficer: dpo,
|
||||
Vendors: vendors,
|
||||
ThirdParties: thirdParties,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,20 +457,20 @@ ORDER BY pa.name ASC;
|
||||
return content, len(listRows), nil
|
||||
}
|
||||
|
||||
func loadVendorsForSnapshot(ctx context.Context, tx pg.Tx, snapshotID string) (map[gid.GID][]string, error) {
|
||||
func loadThirdPartiesForSnapshot(ctx context.Context, tx pg.Tx, snapshotID string) (map[gid.GID][]string, error) {
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT pav.processing_activity_id, v.name
|
||||
FROM processing_activity_vendors pav
|
||||
INNER JOIN vendors v ON v.id = pav.vendor_id
|
||||
FROM processing_activity_third_parties pav
|
||||
INNER JOIN third_parties v ON v.id = pav.third_party_id
|
||||
WHERE pav.snapshot_id = @snapshot_id
|
||||
ORDER BY pav.processing_activity_id, v.name;
|
||||
`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot vendors: %w", err)
|
||||
return nil, fmt.Errorf("cannot load snapshot thirdParties: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -479,7 +479,7 @@ ORDER BY pav.processing_activity_id, v.name;
|
||||
var paID gid.GID
|
||||
var name string
|
||||
if err := rows.Scan(&paID, &name); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan vendor row: %w", err)
|
||||
return nil, fmt.Errorf("cannot scan thirdParty row: %w", err)
|
||||
}
|
||||
result[paID] = append(result[paID], name)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// Command migrate-vendor-snapshots-to-documents creates documents and document
|
||||
// versions from existing vendor snapshots. For each organization that has vendor
|
||||
// snapshots, it generates a vendor list document using the same ProseMirror
|
||||
// Command migrate-thirdParty-snapshots-to-documents creates documents and document
|
||||
// versions from existing thirdParty snapshots. For each organization that has thirdParty
|
||||
// snapshots, it generates a thirdParty list document using the same ProseMirror
|
||||
// builder as the publish flow.
|
||||
package main
|
||||
|
||||
@@ -72,22 +72,22 @@ func run() error {
|
||||
return migrate(ctx, pgClient, dryRun)
|
||||
}
|
||||
|
||||
type orgWithVendorSnapshots struct {
|
||||
type orgWithThirdPartySnapshots struct {
|
||||
organizationID gid.GID
|
||||
tenantID gid.TenantID
|
||||
organizationName string
|
||||
}
|
||||
|
||||
type vendorSnapshot struct {
|
||||
type thirdPartySnapshot struct {
|
||||
snapshotID string
|
||||
publishedAt time.Time
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
|
||||
var orgs []orgWithVendorSnapshots
|
||||
var orgs []orgWithThirdPartySnapshots
|
||||
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
var err error
|
||||
orgs, err = loadOrgsWithVendorSnapshots(ctx, conn)
|
||||
orgs, err = loadOrgsWithThirdPartySnapshots(ctx, conn)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -95,7 +95,7 @@ func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
|
||||
}
|
||||
|
||||
if len(orgs) == 0 {
|
||||
fmt.Println("no organizations with vendor snapshots to migrate")
|
||||
fmt.Println("no organizations with thirdParty snapshots to migrate")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -107,14 +107,14 @@ func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
|
||||
if dryRun {
|
||||
var count int
|
||||
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
snapshots, err := loadVendorSnapshots(ctx, conn, org.organizationID)
|
||||
snapshots, err := loadThirdPartySnapshots(ctx, conn, org.organizationID)
|
||||
count = len(snapshots)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("would migrate org %s (%s) — %d vendor snapshot(s)\n",
|
||||
fmt.Printf("would migrate org %s (%s) — %d thirdParty snapshot(s)\n",
|
||||
org.organizationID, org.organizationName, count)
|
||||
continue
|
||||
}
|
||||
@@ -143,8 +143,8 @@ func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithVendorSnapshots) error {
|
||||
snapshots, err := loadVendorSnapshots(ctx, tx, org.organizationID)
|
||||
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithThirdPartySnapshots) error {
|
||||
snapshots, err := loadThirdPartySnapshots(ctx, tx, org.organizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -186,15 +186,15 @@ INSERT INTO documents (
|
||||
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
`INSERT INTO generated_documents (organization_id, tenant_id, vendors_document_id, created_at, updated_at)
|
||||
VALUES (@organization_id, @tenant_id, @vendors_document_id, @created_at, @updated_at)
|
||||
ON CONFLICT (organization_id) DO UPDATE SET vendors_document_id = @vendors_document_id, updated_at = @updated_at`,
|
||||
`INSERT INTO generated_documents (organization_id, tenant_id, third_parties_document_id, created_at, updated_at)
|
||||
VALUES (@organization_id, @tenant_id, @third_parties_document_id, @created_at, @updated_at)
|
||||
ON CONFLICT (organization_id) DO UPDATE SET third_parties_document_id = @third_parties_document_id, updated_at = @updated_at`,
|
||||
pgx.NamedArgs{
|
||||
"organization_id": org.organizationID,
|
||||
"tenant_id": org.tenantID,
|
||||
"vendors_document_id": documentID,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"organization_id": org.organizationID,
|
||||
"tenant_id": org.tenantID,
|
||||
"third_parties_document_id": documentID,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -234,7 +234,7 @@ INSERT INTO document_versions (
|
||||
"tenant_id": org.tenantID,
|
||||
"organization_id": org.organizationID,
|
||||
"document_id": documentID,
|
||||
"title": "Vendors",
|
||||
"title": "ThirdParties",
|
||||
"major": major + 1,
|
||||
"content": content,
|
||||
"published_at": snap.publishedAt,
|
||||
@@ -251,7 +251,7 @@ INSERT INTO document_versions (
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadOrgsWithVendorSnapshots(ctx context.Context, conn pg.Querier) ([]orgWithVendorSnapshots, error) {
|
||||
func loadOrgsWithThirdPartySnapshots(ctx context.Context, conn pg.Querier) ([]orgWithThirdPartySnapshots, error) {
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
`
|
||||
@@ -263,7 +263,7 @@ SELECT DISTINCT
|
||||
FROM organizations o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM generated_documents gd
|
||||
WHERE gd.organization_id = o.id AND gd.vendors_document_id IS NOT NULL
|
||||
WHERE gd.organization_id = o.id AND gd.third_parties_document_id IS NOT NULL
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM snapshots s
|
||||
@@ -273,13 +273,13 @@ ORDER BY o.created_at;
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query organizations with vendor snapshots: %w", err)
|
||||
return nil, fmt.Errorf("cannot query organizations with thirdParty snapshots: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []orgWithVendorSnapshots
|
||||
var result []orgWithThirdPartySnapshots
|
||||
for rows.Next() {
|
||||
var o orgWithVendorSnapshots
|
||||
var o orgWithThirdPartySnapshots
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan organization: %w", err)
|
||||
@@ -290,7 +290,7 @@ ORDER BY o.created_at;
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadVendorSnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]vendorSnapshot, error) {
|
||||
func loadThirdPartySnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]thirdPartySnapshot, error) {
|
||||
rows, err := conn.Query(
|
||||
ctx,
|
||||
`
|
||||
@@ -305,13 +305,13 @@ ORDER BY s.created_at ASC;
|
||||
pgx.NamedArgs{"organization_id": organizationID},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query vendor snapshots for org %s: %w", organizationID, err)
|
||||
return nil, fmt.Errorf("cannot query thirdParty snapshots for org %s: %w", organizationID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []vendorSnapshot
|
||||
var result []thirdPartySnapshot
|
||||
for rows.Next() {
|
||||
var s vendorSnapshot
|
||||
var s thirdPartySnapshot
|
||||
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
|
||||
}
|
||||
@@ -321,7 +321,7 @@ ORDER BY s.created_at ASC;
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
type vendorInfo struct {
|
||||
type thirdPartyInfo struct {
|
||||
id string
|
||||
name string
|
||||
category string
|
||||
@@ -352,7 +352,7 @@ func buildSnapshotContent(
|
||||
orgName string,
|
||||
publishedAt time.Time,
|
||||
) (string, error) {
|
||||
vendorRows, err := tx.Query(
|
||||
thirdPartyRows, err := tx.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
@@ -376,7 +376,7 @@ SELECT
|
||||
v.countries,
|
||||
COALESCE(bo.full_name, 'Not assigned'),
|
||||
COALESCE(so.full_name, 'Not assigned')
|
||||
FROM vendors v
|
||||
FROM third_parties v
|
||||
LEFT JOIN iam_membership_profiles bo ON bo.id = v.business_owner_profile_id
|
||||
LEFT JOIN iam_membership_profiles so ON so.id = v.security_owner_profile_id
|
||||
WHERE v.snapshot_id = @snapshot_id
|
||||
@@ -385,14 +385,14 @@ ORDER BY v.name ASC;
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot load snapshot vendors: %w", err)
|
||||
return "", fmt.Errorf("cannot load snapshot thirdParties: %w", err)
|
||||
}
|
||||
defer vendorRows.Close()
|
||||
defer thirdPartyRows.Close()
|
||||
|
||||
var vendors []vendorInfo
|
||||
for vendorRows.Next() {
|
||||
var v vendorInfo
|
||||
if err := vendorRows.Scan(
|
||||
var thirdParties []thirdPartyInfo
|
||||
for thirdPartyRows.Next() {
|
||||
var v thirdPartyInfo
|
||||
if err := thirdPartyRows.Scan(
|
||||
&v.id, &v.name, &v.category,
|
||||
&v.legalName, &v.description, &v.headquarterAddress,
|
||||
&v.websiteURL, &v.privacyPolicyURL, &v.serviceLevelAgreementURL,
|
||||
@@ -402,52 +402,52 @@ ORDER BY v.name ASC;
|
||||
&v.certifications, &v.countries,
|
||||
&v.businessOwnerName, &v.securityOwnerName,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("cannot scan vendor: %w", err)
|
||||
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
|
||||
}
|
||||
vendors = append(vendors, v)
|
||||
thirdParties = append(thirdParties, v)
|
||||
}
|
||||
if err := vendorRows.Err(); err != nil {
|
||||
if err := thirdPartyRows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
vendorIDs := make([]string, len(vendors))
|
||||
for i, v := range vendors {
|
||||
vendorIDs[i] = v.id
|
||||
thirdPartyIDs := make([]string, len(thirdParties))
|
||||
for i, v := range thirdParties {
|
||||
thirdPartyIDs[i] = v.id
|
||||
}
|
||||
|
||||
servicesByVendor, err := loadSnapshotServices(ctx, tx, snapshotID, vendorIDs)
|
||||
servicesByThirdParty, err := loadSnapshotServices(ctx, tx, snapshotID, thirdPartyIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
contactsByVendor, err := loadSnapshotContacts(ctx, tx, snapshotID, vendorIDs)
|
||||
contactsByThirdParty, err := loadSnapshotContacts(ctx, tx, snapshotID, thirdPartyIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
assessmentsByVendor, err := loadSnapshotRiskAssessments(ctx, tx, snapshotID, vendorIDs)
|
||||
assessmentsByThirdParty, err := loadSnapshotRiskAssessments(ctx, tx, snapshotID, thirdPartyIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
reportsByVendor, err := loadSnapshotComplianceReports(ctx, tx, snapshotID, vendorIDs)
|
||||
reportsByThirdParty, err := loadSnapshotComplianceReports(ctx, tx, snapshotID, thirdPartyIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
baaByVendor, err := loadSnapshotBAAs(ctx, tx, snapshotID, vendorIDs)
|
||||
baaByThirdParty, err := loadSnapshotBAAs(ctx, tx, snapshotID, thirdPartyIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dpaByVendor, err := loadSnapshotDPAs(ctx, tx, snapshotID, vendorIDs)
|
||||
dpaByThirdParty, err := loadSnapshotDPAs(ctx, tx, snapshotID, thirdPartyIDs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
rows := make([]docgen.VendorListRow, 0, len(vendors))
|
||||
for _, v := range vendors {
|
||||
row := docgen.VendorListRow{
|
||||
rows := make([]docgen.ThirdPartyListRow, 0, len(thirdParties))
|
||||
for _, v := range thirdParties {
|
||||
row := docgen.ThirdPartyListRow{
|
||||
Name: v.name,
|
||||
LegalName: deref(v.legalName),
|
||||
Description: deref(v.description),
|
||||
@@ -467,99 +467,99 @@ ORDER BY v.name ASC;
|
||||
Countries: joinOrDefault(v.countries),
|
||||
BusinessOwner: v.businessOwnerName,
|
||||
SecurityOwner: v.securityOwnerName,
|
||||
Services: servicesByVendor[v.id],
|
||||
Contacts: contactsByVendor[v.id],
|
||||
RiskAssessments: assessmentsByVendor[v.id],
|
||||
ComplianceReports: reportsByVendor[v.id],
|
||||
BusinessAssociateAgreement: baaByVendor[v.id],
|
||||
DataPrivacyAgreement: dpaByVendor[v.id],
|
||||
Services: servicesByThirdParty[v.id],
|
||||
Contacts: contactsByThirdParty[v.id],
|
||||
RiskAssessments: assessmentsByThirdParty[v.id],
|
||||
ComplianceReports: reportsByThirdParty[v.id],
|
||||
BusinessAssociateAgreement: baaByThirdParty[v.id],
|
||||
DataPrivacyAgreement: dpaByThirdParty[v.id],
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
docData := docgen.VendorListData{
|
||||
Title: "Vendors",
|
||||
OrganizationName: orgName,
|
||||
CreatedAt: publishedAt,
|
||||
TotalVendors: len(rows),
|
||||
Rows: rows,
|
||||
docData := docgen.ThirdPartyListData{
|
||||
Title: "ThirdParties",
|
||||
OrganizationName: orgName,
|
||||
CreatedAt: publishedAt,
|
||||
TotalThirdParties: len(rows),
|
||||
Rows: rows,
|
||||
}
|
||||
|
||||
return probo.BuildVendorListDocument(docData)
|
||||
return probo.BuildThirdPartyListDocument(docData)
|
||||
}
|
||||
|
||||
func loadSnapshotServices(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListService, error) {
|
||||
func loadSnapshotServices(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListService, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT vs.vendor_id, vs.name, COALESCE(vs.description, 'Not specified')
|
||||
FROM vendor_services vs
|
||||
WHERE vs.snapshot_id = @snapshot_id AND vs.vendor_id = ANY(@vendor_ids)
|
||||
ORDER BY vs.vendor_id, vs.name ASC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
|
||||
`SELECT vs.third_party_id, vs.name, COALESCE(vs.description, 'Not specified')
|
||||
FROM third_party_services vs
|
||||
WHERE vs.snapshot_id = @snapshot_id AND vs.third_party_id = ANY(@third_party_ids)
|
||||
ORDER BY vs.third_party_id, vs.name ASC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot services: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]docgen.VendorListService)
|
||||
result := make(map[string][]docgen.ThirdPartyListService)
|
||||
for rows.Next() {
|
||||
var vendorID, name, desc string
|
||||
if err := rows.Scan(&vendorID, &name, &desc); err != nil {
|
||||
var thirdPartyID, name, desc string
|
||||
if err := rows.Scan(&thirdPartyID, &name, &desc); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan service: %w", err)
|
||||
}
|
||||
result[vendorID] = append(result[vendorID], docgen.VendorListService{Name: name, Description: desc})
|
||||
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListService{Name: name, Description: desc})
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshotContacts(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListContact, error) {
|
||||
func loadSnapshotContacts(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListContact, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT vc.vendor_id,
|
||||
`SELECT vc.third_party_id,
|
||||
COALESCE(vc.full_name, 'Not specified'),
|
||||
COALESCE(vc.email, 'Not specified'),
|
||||
COALESCE(vc.phone, 'Not specified'),
|
||||
COALESCE(vc.role, 'Not specified')
|
||||
FROM vendor_contacts vc
|
||||
WHERE vc.snapshot_id = @snapshot_id AND vc.vendor_id = ANY(@vendor_ids)
|
||||
ORDER BY vc.vendor_id, vc.full_name ASC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
|
||||
FROM third_party_contacts vc
|
||||
WHERE vc.snapshot_id = @snapshot_id AND vc.third_party_id = ANY(@third_party_ids)
|
||||
ORDER BY vc.third_party_id, vc.full_name ASC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot contacts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]docgen.VendorListContact)
|
||||
result := make(map[string][]docgen.ThirdPartyListContact)
|
||||
for rows.Next() {
|
||||
var vendorID, name, email, phone, role string
|
||||
if err := rows.Scan(&vendorID, &name, &email, &phone, &role); err != nil {
|
||||
var thirdPartyID, name, email, phone, role string
|
||||
if err := rows.Scan(&thirdPartyID, &name, &email, &phone, &role); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan contact: %w", err)
|
||||
}
|
||||
result[vendorID] = append(result[vendorID], docgen.VendorListContact{
|
||||
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListContact{
|
||||
FullName: name, Email: email, Phone: phone, Role: role,
|
||||
})
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListRiskAssessment, error) {
|
||||
func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListRiskAssessment, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT vra.vendor_id, vra.created_at, vra.expires_at, vra.data_sensitivity, vra.business_impact, COALESCE(vra.notes, 'Not specified')
|
||||
FROM vendor_risk_assessments vra
|
||||
WHERE vra.snapshot_id = @snapshot_id AND vra.vendor_id = ANY(@vendor_ids)
|
||||
ORDER BY vra.vendor_id, vra.created_at DESC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
|
||||
`SELECT vra.third_party_id, vra.created_at, vra.expires_at, vra.data_sensitivity, vra.business_impact, COALESCE(vra.notes, 'Not specified')
|
||||
FROM third_party_risk_assessments vra
|
||||
WHERE vra.snapshot_id = @snapshot_id AND vra.third_party_id = ANY(@third_party_ids)
|
||||
ORDER BY vra.third_party_id, vra.created_at DESC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot risk assessments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]docgen.VendorListRiskAssessment)
|
||||
result := make(map[string][]docgen.ThirdPartyListRiskAssessment)
|
||||
for rows.Next() {
|
||||
var vendorID, sensitivity, impact, notes string
|
||||
var thirdPartyID, sensitivity, impact, notes string
|
||||
var assessedAt, expiresAt time.Time
|
||||
if err := rows.Scan(&vendorID, &assessedAt, &expiresAt, &sensitivity, &impact, ¬es); err != nil {
|
||||
if err := rows.Scan(&thirdPartyID, &assessedAt, &expiresAt, &sensitivity, &impact, ¬es); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan risk assessment: %w", err)
|
||||
}
|
||||
result[vendorID] = append(result[vendorID], docgen.VendorListRiskAssessment{
|
||||
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListRiskAssessment{
|
||||
AssessedAt: assessedAt.Format("2006-01-02"),
|
||||
ExpiresAt: expiresAt.Format("2006-01-02"),
|
||||
DataSensitivity: sensitivity,
|
||||
@@ -570,81 +570,81 @@ func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID strin
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshotComplianceReports(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListComplianceReport, error) {
|
||||
func loadSnapshotComplianceReports(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListComplianceReport, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT vcr.vendor_id, vcr.report_name, vcr.report_date, vcr.valid_until
|
||||
FROM vendor_compliance_reports vcr
|
||||
WHERE vcr.snapshot_id = @snapshot_id AND vcr.vendor_id = ANY(@vendor_ids)
|
||||
ORDER BY vcr.vendor_id, vcr.report_date DESC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
|
||||
`SELECT vcr.third_party_id, vcr.report_name, vcr.report_date, vcr.valid_until
|
||||
FROM third_party_compliance_reports vcr
|
||||
WHERE vcr.snapshot_id = @snapshot_id AND vcr.third_party_id = ANY(@third_party_ids)
|
||||
ORDER BY vcr.third_party_id, vcr.report_date DESC`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot compliance reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]docgen.VendorListComplianceReport)
|
||||
result := make(map[string][]docgen.ThirdPartyListComplianceReport)
|
||||
for rows.Next() {
|
||||
var vendorID, name string
|
||||
var thirdPartyID, name string
|
||||
var reportDate time.Time
|
||||
var validUntil *time.Time
|
||||
if err := rows.Scan(&vendorID, &name, &reportDate, &validUntil); err != nil {
|
||||
if err := rows.Scan(&thirdPartyID, &name, &reportDate, &validUntil); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan compliance report: %w", err)
|
||||
}
|
||||
vu := "Not specified"
|
||||
if validUntil != nil {
|
||||
vu = validUntil.Format("2006-01-02")
|
||||
}
|
||||
result[vendorID] = append(result[vendorID], docgen.VendorListComplianceReport{
|
||||
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListComplianceReport{
|
||||
ReportName: name, ReportDate: reportDate.Format("2006-01-02"), ValidUntil: vu,
|
||||
})
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshotBAAs(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string]*docgen.VendorListAgreement, error) {
|
||||
func loadSnapshotBAAs(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string]*docgen.ThirdPartyListAgreement, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT vbaa.vendor_id, vbaa.valid_from, vbaa.valid_until
|
||||
FROM vendor_business_associate_agreements vbaa
|
||||
WHERE vbaa.snapshot_id = @snapshot_id AND vbaa.vendor_id = ANY(@vendor_ids)`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
|
||||
`SELECT vbaa.third_party_id, vbaa.valid_from, vbaa.valid_until
|
||||
FROM third_party_business_associate_agreements vbaa
|
||||
WHERE vbaa.snapshot_id = @snapshot_id AND vbaa.third_party_id = ANY(@third_party_ids)`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot BAAs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]*docgen.VendorListAgreement)
|
||||
result := make(map[string]*docgen.ThirdPartyListAgreement)
|
||||
for rows.Next() {
|
||||
var vendorID string
|
||||
var thirdPartyID string
|
||||
var validFrom, validUntil *time.Time
|
||||
if err := rows.Scan(&vendorID, &validFrom, &validUntil); err != nil {
|
||||
if err := rows.Scan(&thirdPartyID, &validFrom, &validUntil); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan BAA: %w", err)
|
||||
}
|
||||
result[vendorID] = &docgen.VendorListAgreement{
|
||||
result[thirdPartyID] = &docgen.ThirdPartyListAgreement{
|
||||
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshotDPAs(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string]*docgen.VendorListAgreement, error) {
|
||||
func loadSnapshotDPAs(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string]*docgen.ThirdPartyListAgreement, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT vdpa.vendor_id, vdpa.valid_from, vdpa.valid_until
|
||||
FROM vendor_data_privacy_agreements vdpa
|
||||
WHERE vdpa.snapshot_id = @snapshot_id AND vdpa.vendor_id = ANY(@vendor_ids)`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
|
||||
`SELECT vdpa.third_party_id, vdpa.valid_from, vdpa.valid_until
|
||||
FROM third_party_data_privacy_agreements vdpa
|
||||
WHERE vdpa.snapshot_id = @snapshot_id AND vdpa.third_party_id = ANY(@third_party_ids)`,
|
||||
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load snapshot DPAs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]*docgen.VendorListAgreement)
|
||||
result := make(map[string]*docgen.ThirdPartyListAgreement)
|
||||
for rows.Next() {
|
||||
var vendorID string
|
||||
var thirdPartyID string
|
||||
var validFrom, validUntil *time.Time
|
||||
if err := rows.Scan(&vendorID, &validFrom, &validUntil); err != nil {
|
||||
if err := rows.Scan(&thirdPartyID, &validFrom, &validUntil); err != nil {
|
||||
return nil, fmt.Errorf("cannot scan DPA: %w", err)
|
||||
}
|
||||
result[vendorID] = &docgen.VendorListAgreement{
|
||||
result[thirdPartyID] = &docgen.ThirdPartyListAgreement{
|
||||
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,12 @@ The `pages/` folder **is** the route tree. Every route segment maps to a folder
|
||||
// Bad — separate routes/ folder duplicates pages/ structure
|
||||
src/
|
||||
routes/
|
||||
vendorRoutes.ts # route definitions for vendors
|
||||
thirdPartyRoutes.ts # route definitions for third parties
|
||||
assetRoutes.ts # route definitions for assets
|
||||
pages/
|
||||
organizations/
|
||||
vendors/
|
||||
VendorsPage.tsx
|
||||
third-parties/
|
||||
ThirdPartiesPage.tsx
|
||||
assets/
|
||||
AssetsPage.tsx
|
||||
```
|
||||
@@ -37,9 +37,9 @@ src/
|
||||
src/
|
||||
pages/
|
||||
organizations/
|
||||
vendors/
|
||||
routes.ts # route definitions for vendors
|
||||
VendorsPage.tsx
|
||||
third-parties/
|
||||
routes.ts # route definitions for third parties
|
||||
ThirdPartiesPage.tsx
|
||||
assets/
|
||||
routes.ts # route definitions for assets
|
||||
AssetsPage.tsx
|
||||
@@ -77,11 +77,11 @@ Use the correct suffix so the role is clear from the file name alone:
|
||||
|
||||
```text
|
||||
// Bad — a layout route named as a "Page"
|
||||
VendorDetailPage.tsx # renders <Outlet />, wraps child routes
|
||||
ThirdPartyDetailPage.tsx # renders <Outlet />, wraps child routes
|
||||
CookieBannerConfigPage.tsx # renders tabs + <Outlet />
|
||||
|
||||
// Good — layout routes use the "Layout" suffix
|
||||
VendorDetailLayout.tsx
|
||||
ThirdPartyDetailLayout.tsx
|
||||
CookieBannerConfigLayout.tsx
|
||||
```
|
||||
|
||||
@@ -142,26 +142,26 @@ export default function CookieBannerLayout() {
|
||||
Contains route objects for the current folder's feature, exported as a named array and spread into the parent. Keep imports minimal — only `lazy`, skeleton components, and typing.
|
||||
|
||||
```ts
|
||||
// pages/organizations/vendors/routes.ts
|
||||
// pages/organizations/third-parties/routes.ts
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
|
||||
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";
|
||||
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";
|
||||
|
||||
export const vendorRoutes = [
|
||||
export const thirdPartyRoutes = [
|
||||
{
|
||||
path: "vendors",
|
||||
Fallback: VendorsPageSkeleton,
|
||||
Component: lazy(() => import("./VendorsPageLoader")),
|
||||
path: "third-parties",
|
||||
Fallback: ThirdPartiesPageSkeleton,
|
||||
Component: lazy(() => import("./ThirdPartiesPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "vendors/:vendorId",
|
||||
Fallback: VendorsPageSkeleton,
|
||||
Component: lazy(() => import("./VendorDetailLayoutLoader")),
|
||||
path: "third-parties/:thirdPartyId",
|
||||
Fallback: ThirdPartiesPageSkeleton,
|
||||
Component: lazy(() => import("./ThirdPartyDetailLayoutLoader")),
|
||||
children: [
|
||||
{
|
||||
path: "overview",
|
||||
Component: lazy(() => import("./overview/VendorOverviewPage")),
|
||||
Component: lazy(() => import("./overview/ThirdPartyOverviewPage")),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -173,35 +173,35 @@ export const vendorRoutes = [
|
||||
The loader is the **lazy bundle entry point**. It sets up providers, triggers the Relay query, shows a skeleton until the query resolves, then renders the page.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPageLoader.tsx
|
||||
// pages/organizations/third-parties/ThirdPartiesPageLoader.tsx
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { VendorsPageQuery } from "#/__generated__/core/VendorsPageQuery.graphql";
|
||||
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import VendorsPage, { vendorsPageQuery } from "./VendorsPage";
|
||||
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";
|
||||
import ThirdPartiesPage, { thirdPartiesPageQuery } from "./ThirdPartiesPage";
|
||||
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";
|
||||
|
||||
function VendorsPageQueryLoader() {
|
||||
function ThirdPartiesPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<VendorsPageQuery>(vendorsPageQuery);
|
||||
const [queryRef, loadQuery] = useQueryLoader<ThirdPartiesPageQuery>(thirdPartiesPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ organizationId });
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <VendorsPageSkeleton />;
|
||||
return <ThirdPartiesPageSkeleton />;
|
||||
}
|
||||
|
||||
return <VendorsPage queryRef={queryRef} />
|
||||
return <ThirdPartiesPage queryRef={queryRef} />
|
||||
}
|
||||
|
||||
export default function VendorsPageLoader() {
|
||||
export default function ThirdPartiesPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<VendorsPageQueryLoader />
|
||||
<ThirdPartiesPageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
);
|
||||
}
|
||||
@@ -212,9 +212,9 @@ export default function VendorsPageLoader() {
|
||||
Receives the `queryRef` from the loader and renders the UI. Default export so `lazy()` can import it.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPage.tsx
|
||||
export default function VendorsPage({ queryRef }: VendorsPageProps) {
|
||||
const data = usePreloadedQuery(vendorsPageQuery, queryRef);
|
||||
// pages/organizations/third-parties/ThirdPartiesPage.tsx
|
||||
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
|
||||
const data = usePreloadedQuery(thirdPartiesPageQuery, queryRef);
|
||||
return (/* … */);
|
||||
}
|
||||
```
|
||||
@@ -224,8 +224,8 @@ export default function VendorsPage({ queryRef }: VendorsPageProps) {
|
||||
A lightweight loading placeholder. Keep it free of data-fetching logic so it loads instantly.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPageSkeleton.tsx
|
||||
export function VendorsPageSkeleton() {
|
||||
// pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
|
||||
export function ThirdPartiesPageSkeleton() {
|
||||
return (/* pulse / skeleton UI */);
|
||||
}
|
||||
```
|
||||
@@ -235,8 +235,8 @@ export function VendorsPageSkeleton() {
|
||||
Rendered by the route error boundary when the page throws.
|
||||
|
||||
```tsx
|
||||
// pages/organizations/vendors/VendorsPageError.tsx
|
||||
export function VendorsPageError() {
|
||||
// pages/organizations/third-parties/ThirdPartiesPageError.tsx
|
||||
export function ThirdPartiesPageError() {
|
||||
const error = useRouteError();
|
||||
return (/* error UI */);
|
||||
}
|
||||
@@ -244,24 +244,24 @@ export function VendorsPageError() {
|
||||
|
||||
## File naming
|
||||
|
||||
Component files (`.tsx` that export a React component) use **PascalCase**: `VendorsPage.tsx`, `VendorContactRow.tsx`, `VendorsPageSkeleton.tsx`.
|
||||
Component files (`.tsx` that export a React component) use **PascalCase**: `ThirdPartiesPage.tsx`, `ThirdPartyContactRow.tsx`, `ThirdPartiesPageSkeleton.tsx`.
|
||||
|
||||
All other helper files (utilities, hooks, constants, configuration) use **camelCase**: `routes.ts`, `useVendorFilters.ts`, `formatCurrency.ts`, `constants.ts`.
|
||||
All other helper files (utilities, hooks, constants, configuration) use **camelCase**: `routes.ts`, `useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`.
|
||||
|
||||
### Do / don't: file naming
|
||||
|
||||
```text
|
||||
// Bad — helper file in PascalCase
|
||||
pages/organizations/vendors/FormatVendorStatus.ts
|
||||
pages/organizations/vendors/UseVendorFilters.ts
|
||||
pages/organizations/vendors/Routes.ts
|
||||
pages/organizations/third-parties/FormatThirdPartyStatus.ts
|
||||
pages/organizations/third-parties/UseThirdPartyFilters.ts
|
||||
pages/organizations/third-parties/Routes.ts
|
||||
|
||||
// Good — helpers are camelCase, components are PascalCase
|
||||
pages/organizations/vendors/formatVendorStatus.ts
|
||||
pages/organizations/vendors/useVendorFilters.ts
|
||||
pages/organizations/vendors/routes.ts
|
||||
pages/organizations/vendors/VendorsPage.tsx
|
||||
pages/organizations/vendors/VendorsPageSkeleton.tsx
|
||||
pages/organizations/third-parties/formatThirdPartyStatus.ts
|
||||
pages/organizations/third-parties/useThirdPartyFilters.ts
|
||||
pages/organizations/third-parties/routes.ts
|
||||
pages/organizations/third-parties/ThirdPartiesPage.tsx
|
||||
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
|
||||
```
|
||||
|
||||
## `_components` folder
|
||||
@@ -270,7 +270,7 @@ Sub-components that are used **only** by a single page live in a `_components/`
|
||||
|
||||
| Situation | Where the component lives |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| Used by one page only | `pages/organizations/vendors/_components/` |
|
||||
| Used by one page only | `pages/organizations/third-parties/_components/` |
|
||||
| Used by multiple pages in the same feature | Nearest common ancestor's `_components/` (e.g. `pages/organizations/_components/`) |
|
||||
| Reusable UI primitive | `@probo/ui` package |
|
||||
|
||||
@@ -278,8 +278,8 @@ Sub-components that are used **only** by a single page live in a `_components/`
|
||||
|
||||
```text
|
||||
// Bad — shared component buried in a single page's _components
|
||||
pages/organizations/vendors/_components/StatusBadge.tsx # also used by risks page
|
||||
pages/organizations/risks/SomeRiskPage.tsx # imports ../../vendors/_components/StatusBadge
|
||||
pages/organizations/third-parties/_components/StatusBadge.tsx # also used by risks page
|
||||
pages/organizations/risks/SomeRiskPage.tsx # imports ../../third-parties/_components/StatusBadge
|
||||
|
||||
// Good — shared component hoisted to common ancestor
|
||||
pages/organizations/_components/StatusBadge.tsx
|
||||
@@ -287,10 +287,10 @@ pages/organizations/_components/StatusBadge.tsx
|
||||
|
||||
```text
|
||||
// Bad — page-specific helper placed in a global folder
|
||||
src/components/VendorContactRow.tsx # only used by VendorContactsTab
|
||||
src/components/ThirdPartyContactRow.tsx # only used by ThirdPartyContactsTab
|
||||
|
||||
// Good — scoped to the page that uses it
|
||||
pages/organizations/vendors/_components/VendorContactRow.tsx
|
||||
pages/organizations/third-parties/_components/ThirdPartyContactRow.tsx
|
||||
```
|
||||
|
||||
## Child-route folder naming
|
||||
@@ -303,40 +303,40 @@ Folders that contain child-route pages are named after the **resource or concept
|
||||
// Bad — folder named after a UI element
|
||||
configuration/
|
||||
tabs/ # "tabs" is a UI component, not a resource
|
||||
VendorOverviewTab.tsx
|
||||
VendorComplianceTab.tsx
|
||||
ThirdPartyOverviewTab.tsx
|
||||
ThirdPartyComplianceTab.tsx
|
||||
|
||||
// Good — folders named after the resource each child route represents
|
||||
configuration/
|
||||
overview/
|
||||
VendorOverviewPage.tsx
|
||||
ThirdPartyOverviewPage.tsx
|
||||
compliance/
|
||||
VendorCompliancePage.tsx
|
||||
ThirdPartyCompliancePage.tsx
|
||||
```
|
||||
|
||||
This also means child-route components use the `*Page` suffix (not `*Tab`), because they are pages in their own right — the fact that a tab bar navigates between them is an implementation detail of the parent layout.
|
||||
|
||||
## Full example tree
|
||||
|
||||
Target layout for a `vendors` feature under `pages/organizations/`:
|
||||
Target layout for a `third-parties` feature under `pages/organizations/`:
|
||||
|
||||
```text
|
||||
pages/organizations/vendors/
|
||||
routes.ts # route definitions for vendors
|
||||
VendorsPageLoader.tsx # lazy entry — providers + Suspense + query loader
|
||||
VendorsPage.tsx # page component (usePreloadedQuery)
|
||||
VendorsPageSkeleton.tsx # loading fallback
|
||||
VendorDetailLayoutLoader.tsx # lazy entry for detail layout
|
||||
VendorDetailLayout.tsx # layout — breadcrumbs, tabs, <Outlet />
|
||||
VendorDetailLayoutSkeleton.tsx # detail loading fallback
|
||||
NewVendorPage.tsx # mutation-only page — default export, wraps itself in the Relay provider
|
||||
_components/ # sub-components used only by vendor pages
|
||||
VendorContactRow.tsx
|
||||
VendorRiskSummary.tsx
|
||||
overview/ # child route: /vendors/:vendorId/overview
|
||||
VendorOverviewPage.tsx
|
||||
compliance/ # child route: /vendors/:vendorId/compliance
|
||||
VendorCompliancePage.tsx
|
||||
contacts/ # child route: /vendors/:vendorId/contacts
|
||||
VendorContactsPage.tsx
|
||||
pages/organizations/third-parties/
|
||||
routes.ts # route definitions for third parties
|
||||
ThirdPartiesPageLoader.tsx # lazy entry — providers + Suspense + query loader
|
||||
ThirdPartiesPage.tsx # page component (usePreloadedQuery)
|
||||
ThirdPartiesPageSkeleton.tsx # loading fallback
|
||||
ThirdPartyDetailLayoutLoader.tsx # lazy entry for detail layout
|
||||
ThirdPartyDetailLayout.tsx # layout — breadcrumbs, tabs, <Outlet />
|
||||
ThirdPartyDetailLayoutSkeleton.tsx # detail loading fallback
|
||||
NewThirdPartyPage.tsx # mutation-only page — default export, wraps itself in the Relay provider
|
||||
_components/ # sub-components used only by third party pages
|
||||
ThirdPartyContactRow.tsx
|
||||
ThirdPartyRiskSummary.tsx
|
||||
overview/ # child route: /third-parties/:thirdPartyId/overview
|
||||
ThirdPartyOverviewPage.tsx
|
||||
compliance/ # child route: /third-parties/:thirdPartyId/compliance
|
||||
ThirdPartyCompliancePage.tsx
|
||||
contacts/ # child route: /third-parties/:thirdPartyId/contacts
|
||||
ThirdPartyContactsPage.tsx
|
||||
```
|
||||
|
||||
@@ -8,18 +8,18 @@ Policy-based authorization in `pkg/iam/` using an evaluation model similar to AW
|
||||
|
||||
**Policy** — a named collection of statements:
|
||||
```go
|
||||
policy.NewPolicy("vendor-crud", "Vendor CRUD",
|
||||
policy.Allow(ActionVendorGet, ActionVendorList).WithSID("read-vendors"),
|
||||
policy.Deny(ActionVendorDelete).WithSID("deny-vendor-delete"),
|
||||
).WithDescription("Standard vendor access")
|
||||
policy.NewPolicy("thirdParty-crud", "ThirdParty CRUD",
|
||||
policy.Allow(ActionThirdPartyGet, ActionThirdPartyList).WithSID("read-thirdParties"),
|
||||
policy.Deny(ActionThirdPartyDelete).WithSID("deny-thirdParty-delete"),
|
||||
).WithDescription("Standard third party access")
|
||||
```
|
||||
|
||||
**Statement** — a single permission rule with effect (allow/deny), actions, optional resources, and optional conditions.
|
||||
|
||||
**Action format** — `SERVICE:RESOURCE:OPERATION` with wildcard support:
|
||||
```
|
||||
core:vendor:create # specific action
|
||||
core:vendor:* # all vendor actions
|
||||
core:thirdParty:create # specific action
|
||||
core:thirdParty:* # all third party actions
|
||||
core:* # all core actions
|
||||
* # everything
|
||||
```
|
||||
@@ -39,8 +39,8 @@ The evaluator processes all statements against a request:
|
||||
```go
|
||||
err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{
|
||||
Principal: identityID, // who
|
||||
Resource: vendorID, // what
|
||||
Action: probo.ActionVendorGet, // which action
|
||||
Resource: thirdPartyID, // what
|
||||
Action: probo.ActionThirdPartyGet, // which action
|
||||
ResourceAttributes: map[string]string{}, // optional extra attributes
|
||||
})
|
||||
```
|
||||
@@ -78,8 +78,8 @@ Conditions constrain when a statement applies. All conditions must be satisfied.
|
||||
// Users can only access resources in their organization
|
||||
organizationCondition := policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
|
||||
policy.Allow(ActionVendorGet).
|
||||
WithSID("view-vendor").
|
||||
policy.Allow(ActionThirdPartyGet).
|
||||
WithSID("view-thirdParty").
|
||||
When(organizationCondition)
|
||||
```
|
||||
|
||||
@@ -97,14 +97,14 @@ Key paths use `principal.ATTR` or `resource.ATTR` (e.g., `principal.organization
|
||||
Resources that support authorization must implement this interface in `pkg/coredata/`:
|
||||
|
||||
```go
|
||||
func (v *Vendor) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM vendors WHERE id = $1 LIMIT 1;`
|
||||
func (v *ThirdParty) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM thirdParties WHERE id = $1 LIMIT 1;`
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query vendor authorization attributes: %w", err)
|
||||
return nil, fmt.Errorf("cannot query third party authorization attributes: %w", err)
|
||||
}
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
@@ -126,14 +126,14 @@ var (
|
||||
|
||||
**GraphQL resolvers** use `AuthorizeFunc` from `pkg/server/api/authz/`:
|
||||
```go
|
||||
if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil {
|
||||
if err := authorize(ctx, thirdPartyID, probo.ActionThirdPartyGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
**MCP resolvers** use `MustAuthorize` which panics (caught by middleware):
|
||||
```go
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet)
|
||||
r.MustAuthorize(ctx, input.ID, probo.ActionThirdPartyGet)
|
||||
```
|
||||
|
||||
## File locations
|
||||
@@ -155,11 +155,11 @@ IAM actions live in `pkg/iam/iam_actions.go`, probo actions in `pkg/probo/action
|
||||
|
||||
```go
|
||||
const (
|
||||
ActionVendorGet = "core:vendor:get"
|
||||
ActionVendorList = "core:vendor:list"
|
||||
ActionVendorCreate = "core:vendor:create"
|
||||
ActionVendorUpdate = "core:vendor:update"
|
||||
ActionVendorDelete = "core:vendor:delete"
|
||||
ActionThirdPartyGet = "core:thirdParty:get"
|
||||
ActionThirdPartyList = "core:thirdParty:list"
|
||||
ActionThirdPartyCreate = "core:thirdParty:create"
|
||||
ActionThirdPartyUpdate = "core:thirdParty:update"
|
||||
ActionThirdPartyDelete = "core:thirdParty:delete"
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -13,18 +13,18 @@ Follow the [seven rules of a great Git commit message](https://cbea.ms/git-commi
|
||||
The subject line should complete the sentence: "If applied, this commit will *your subject line here*".
|
||||
|
||||
```
|
||||
Add vendor assessment agent for third-party reviews
|
||||
Add third-party assessment agent for third-party reviews
|
||||
|
||||
The existing changelog generator only covers internal changes.
|
||||
This introduces a dedicated agent that evaluates third-party
|
||||
vendors against our compliance criteria, producing a structured
|
||||
thirdParties against our compliance criteria, producing a structured
|
||||
risk report.
|
||||
```
|
||||
|
||||
Not every commit needs a body -- a single line is fine when the change is self-explanatory:
|
||||
|
||||
```
|
||||
Fix typo in vendor assessment prompt
|
||||
Fix typo in third-party assessment prompt
|
||||
```
|
||||
|
||||
## Signing and Authorship
|
||||
|
||||
@@ -43,7 +43,7 @@ Two patterns in `e2e/internal/factory/`:
|
||||
|
||||
**Builder pattern (preferred):**
|
||||
```go
|
||||
vendorID := factory.NewVendor(owner).
|
||||
thirdPartyID := factory.NewThirdParty(owner).
|
||||
WithName("Stripe").
|
||||
WithCategory("CLOUD_PROVIDER").
|
||||
Create()
|
||||
@@ -59,7 +59,7 @@ controlID := factory.NewControl(owner, frameworkID).
|
||||
|
||||
**Simple factory functions:**
|
||||
```go
|
||||
vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
|
||||
thirdPartyID := factory.CreateThirdParty(c, factory.Attrs{"name": "Acme"})
|
||||
taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"})
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ Use `factory.SafeName("prefix")` for unique names and `factory.SafeEmail()` for
|
||||
Every test and subtest **must** call `t.Parallel()`. One test file per entity in `e2e/console/`. Function naming: `TestEntity_Operation`.
|
||||
|
||||
```go
|
||||
func TestVendor_Create(t *testing.T) {
|
||||
func TestThirdParty_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
@@ -78,9 +78,9 @@ func TestVendor_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id name }
|
||||
}
|
||||
}
|
||||
@@ -88,25 +88,25 @@ func TestVendor_Create(t *testing.T) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": factory.SafeName("Vendor"),
|
||||
"name": factory.SafeName("ThirdParty"),
|
||||
},
|
||||
}, &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID)
|
||||
assert.NotEmpty(t, result.CreateThirdParty.ThirdPartyEdge.Node.ID)
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -139,13 +139,13 @@ t.Run("other org cannot access", func(t *testing.T) {
|
||||
owner1 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
owner2 := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
vendorID := factory.NewVendor(owner1).WithName("Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner1).WithName("ThirdParty").Create()
|
||||
|
||||
var result struct {
|
||||
Node *struct{ ID string } `json:"node"`
|
||||
}
|
||||
err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result)
|
||||
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor")
|
||||
err := owner2.Execute(nodeQuery, map[string]any{"id": thirdPartyID}, &result)
|
||||
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "ThirdParty")
|
||||
})
|
||||
```
|
||||
|
||||
@@ -214,7 +214,7 @@ for _, tt := range tests {
|
||||
```go
|
||||
err := owner.ExecuteWithFile(
|
||||
uploadQuery,
|
||||
map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}},
|
||||
map[string]any{"input": map[string]any{"thirdPartyId": thirdPartyID, "file": nil}},
|
||||
"input.file",
|
||||
testutil.UploadFile{
|
||||
Filename: "report.pdf",
|
||||
|
||||
@@ -131,7 +131,7 @@ if errors.As(err, &ve) {
|
||||
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
|
||||
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`)
|
||||
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
|
||||
- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`)
|
||||
- Unexported types for internal data: lowercase (e.g. `thirdPartyInfo`, `ctxKey`)
|
||||
|
||||
## Functional options and Config structs
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ Schema-first GraphQL using [gqlgen](https://gqlgen.com/). The schema is hand-wri
|
||||
Each API's schema lives in `pkg/server/api/{api}/v1/graphql/` as multiple `.graphql` files, one per coredata model:
|
||||
|
||||
- `base.graphql` — directives, scalars, Node interface, PageInfo, OrderDirection, root Query/Mutation/Organization types
|
||||
- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations.
|
||||
- Entity files (e.g., `thirdParty.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations.
|
||||
|
||||
gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `vendor.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`.
|
||||
gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `thirdParty.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`.
|
||||
|
||||
### `extend type` restrictions
|
||||
|
||||
@@ -20,18 +20,18 @@ gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g
|
||||
**Always define a custom Go type for connection types** using the `@goModel` directive. The model path points to the `types` package for the relevant API. The `totalCount` field must use `@goField(forceResolver: true)`. Edge types do not need `@goModel`.
|
||||
|
||||
```graphql
|
||||
type VendorConnection
|
||||
type ThirdPartyConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [VendorEdge!]!
|
||||
edges: [ThirdPartyEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type VendorEdge {
|
||||
type ThirdPartyEdge {
|
||||
cursor: CursorKey!
|
||||
node: Vendor!
|
||||
node: ThirdParty!
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,12 +42,12 @@ Without `@goModel`, gqlgen generates a default struct that lacks the custom fiel
|
||||
Map GraphQL enums to existing Go types using `@goModel` on the enum and `@goEnum` on each value:
|
||||
|
||||
```graphql
|
||||
enum VendorOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.VendorOrderField") {
|
||||
enum ThirdPartyOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderField") {
|
||||
CREATED_AT
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldCreatedAt")
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderFieldCreatedAt")
|
||||
NAME
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldName")
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderFieldName")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -88,14 +88,14 @@ Connection fields on parent types use standard Relay arguments:
|
||||
|
||||
```graphql
|
||||
type Organization {
|
||||
vendors(
|
||||
thirdParties(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: VendorOrder
|
||||
filter: VendorFilter
|
||||
): VendorConnection!
|
||||
orderBy: ThirdPartyOrder
|
||||
filter: ThirdPartyFilter
|
||||
): ThirdPartyConnection!
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,11 +105,11 @@ Each connection type lives in `types/*_connection.go` and follows this structure
|
||||
|
||||
```go
|
||||
type (
|
||||
VendorOrderBy OrderBy[coredata.VendorOrderField]
|
||||
ThirdPartyOrderBy OrderBy[coredata.ThirdPartyOrderField]
|
||||
|
||||
VendorConnection struct {
|
||||
ThirdPartyConnection struct {
|
||||
TotalCount int
|
||||
Edges []*VendorEdge
|
||||
Edges []*ThirdPartyEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
@@ -117,17 +117,17 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func NewVendorConnection(
|
||||
p *page.Page[*coredata.Vendor, coredata.VendorOrderField],
|
||||
func NewThirdPartyConnection(
|
||||
p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *VendorConnection {
|
||||
edges := make([]*VendorEdge, len(p.Data))
|
||||
) *ThirdPartyConnection {
|
||||
edges := make([]*ThirdPartyEdge, len(p.Data))
|
||||
for i, v := range p.Data {
|
||||
edges[i] = NewVendorEdge(v, p.Cursor.OrderBy.Field)
|
||||
edges[i] = NewThirdPartyEdge(v, p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &VendorConnection{
|
||||
return &ThirdPartyConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
|
||||
@@ -136,13 +136,13 @@ func NewVendorConnection(
|
||||
}
|
||||
}
|
||||
|
||||
func NewVendorEdge(
|
||||
v *coredata.Vendor,
|
||||
orderBy coredata.VendorOrderField,
|
||||
) *VendorEdge {
|
||||
return &VendorEdge{
|
||||
func NewThirdPartyEdge(
|
||||
v *coredata.ThirdParty,
|
||||
orderBy coredata.ThirdPartyOrderField,
|
||||
) *ThirdPartyEdge {
|
||||
return &ThirdPartyEdge{
|
||||
Cursor: v.CursorKey(orderBy),
|
||||
Node: NewVendor(v),
|
||||
Node: NewThirdParty(v),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ MCP tools are defined in `pkg/server/api/mcp/v1/specification.yaml` and generate
|
||||
- `specification.yaml` — tool definitions, input/output schemas, component schemas
|
||||
- `resolver.go` — `Resolver` struct, `MustAuthorize`, service accessors
|
||||
- `helpers.go` — pagination helpers, `UnwrapOmittable`
|
||||
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewVendor()`, `NewListVendorsOutput()`, etc.)
|
||||
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewThirdParty()`, `NewListThirdPartiesOutput()`, etc.)
|
||||
- `schema.resolvers.go` — tool implementation bodies (stubs generated, you edit the bodies)
|
||||
|
||||
**Generated** (do not edit):
|
||||
@@ -24,16 +24,16 @@ go generate ./pkg/server/api/mcp/v1
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- name: listVendors
|
||||
description: List all vendors for the organization
|
||||
- name: listThirdParties
|
||||
description: List all thirdParties for the organization
|
||||
hints:
|
||||
readonly: true
|
||||
idempotent: true
|
||||
destructive: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsInput"
|
||||
$ref: "#/components/schemas/ListThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListVendorsOutput"
|
||||
$ref: "#/components/schemas/ListThirdPartiesOutput"
|
||||
```
|
||||
|
||||
Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension:
|
||||
@@ -51,11 +51,11 @@ components:
|
||||
Generated stubs follow this pattern:
|
||||
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(
|
||||
func (r *Resolver) ListThirdPartiesTool(
|
||||
ctx context.Context,
|
||||
req *mcp.CallToolRequest,
|
||||
input *types.ListVendorsInput,
|
||||
) (*mcp.CallToolResult, types.ListVendorsOutput, error)
|
||||
input *types.ListThirdPartiesInput,
|
||||
) (*mcp.CallToolResult, types.ListThirdPartiesOutput, error)
|
||||
```
|
||||
|
||||
First return is always `nil`. Errors are either returned (for recoverable) or panicked (for authorization and unexpected failures).
|
||||
@@ -65,24 +65,24 @@ First return is always `nil`. Errors are either returned (for recoverable) or pa
|
||||
Use `MustAuthorize` which panics on failure (caught by middleware):
|
||||
|
||||
```go
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionThirdPartyList)
|
||||
```
|
||||
|
||||
## Common resolver patterns
|
||||
|
||||
**List with pagination:**
|
||||
```go
|
||||
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||
func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListThirdPartiesInput) (*mcp.CallToolResult, types.ListThirdPartiesOutput, error) {
|
||||
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionThirdPartyList)
|
||||
|
||||
prb := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
|
||||
Field: coredata.VendorOrderFieldCreatedAt,
|
||||
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: coredata.ThirdPartyOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if input.OrderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.VendorOrderField]{
|
||||
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: input.OrderBy.Field,
|
||||
Direction: input.OrderBy.Direction,
|
||||
}
|
||||
@@ -90,12 +90,12 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewVendorFilter(nil, nil))
|
||||
page, err := prb.ThirdParties.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, nil))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list vendors: %w", err))
|
||||
panic(fmt.Errorf("cannot list thirdParties: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.NewListVendorsOutput(page), nil
|
||||
return nil, types.NewListThirdPartiesOutput(page), nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -156,8 +156,8 @@ Description: UnwrapOmittable(input.Description),
|
||||
Live in `types/*.go` (not the generated `types/types.go`). One file per entity:
|
||||
|
||||
```go
|
||||
func NewVendor(v *coredata.Vendor) *Vendor {
|
||||
return &Vendor{
|
||||
func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
return &ThirdParty{
|
||||
ID: v.ID,
|
||||
OrganizationID: v.OrganizationID,
|
||||
Name: v.Name,
|
||||
@@ -166,21 +166,21 @@ func NewVendor(v *coredata.Vendor) *Vendor {
|
||||
}
|
||||
}
|
||||
|
||||
func NewListVendorsOutput(vendorPage *page.Page[*coredata.Vendor, coredata.VendorOrderField]) ListVendorsOutput {
|
||||
vendors := make([]*Vendor, 0, len(vendorPage.Data))
|
||||
for _, v := range vendorPage.Data {
|
||||
vendors = append(vendors, NewVendor(v))
|
||||
func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListThirdPartiesOutput {
|
||||
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
|
||||
for _, v := range thirdPartyPage.Data {
|
||||
thirdParties = append(thirdParties, NewThirdParty(v))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
if len(vendorPage.Data) > 0 {
|
||||
cursorKey := vendorPage.Data[len(vendorPage.Data)-1].CursorKey(vendorPage.Cursor.OrderBy.Field)
|
||||
if len(thirdPartyPage.Data) > 0 {
|
||||
cursorKey := thirdPartyPage.Data[len(thirdPartyPage.Data)-1].CursorKey(thirdPartyPage.Cursor.OrderBy.Field)
|
||||
nextCursor = &cursorKey
|
||||
}
|
||||
|
||||
return ListVendorsOutput{
|
||||
return ListThirdPartiesOutput{
|
||||
NextCursor: nextCursor,
|
||||
Vendors: vendors,
|
||||
ThirdParties: thirdParties,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -79,10 +79,10 @@ export function UserCard({ name }: UserCardProps) {
|
||||
|
||||
```tsx
|
||||
// Good — destructure in body when parameter-level destructuring would exceed the line-length limit
|
||||
export function VendorComplianceOverviewPanel(
|
||||
props: VendorComplianceOverviewPanelProps,
|
||||
export function ThirdPartyComplianceOverviewPanel(
|
||||
props: ThirdPartyComplianceOverviewPanelProps,
|
||||
) {
|
||||
const { className, vendorKey, onStatusChange } = props;
|
||||
const { className, thirdPartyKey, onStatusChange } = props;
|
||||
// …
|
||||
}
|
||||
```
|
||||
@@ -139,11 +139,11 @@ export function Thing({ label }: ThingProps) {
|
||||
|
||||
```tsx
|
||||
// Good — rare exception: route entry default export (names still clear in module)
|
||||
type VendorsPageProps = {
|
||||
queryRef: PreloadedQuery<VendorsQuery>;
|
||||
type ThirdPartiesPageProps = {
|
||||
queryRef: PreloadedQuery<ThirdPartiesQuery>;
|
||||
};
|
||||
|
||||
export default function VendorsPage({ queryRef }: VendorsPageProps) {
|
||||
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
|
||||
// …
|
||||
}
|
||||
```
|
||||
@@ -204,7 +204,7 @@ Use props for:
|
||||
### Hooks for data and URL-derived identity
|
||||
|
||||
- **Fetched data:** Colocate Relay fragments and queries per [`contrib/claude/relay.md`](relay.md) (`useFragment`, `useLazyLoadQuery`, `usePreloadedQuery`, etc.) in the component that needs the data.
|
||||
- **Route parameters:** Call `useParams()` (or a small `useOrganizationId()`-style hook) **inside** the component that needs the id — avoid drilling `organizationId` / `vendorId` from a parent that only read the URL to pass them down.
|
||||
- **Route parameters:** Call `useParams()` (or a small `useOrganizationId()`-style hook) **inside** the component that needs the id — avoid drilling `organizationId` / `thirdPartyId` from a parent that only read the URL to pass them down.
|
||||
|
||||
### Relay: framework wiring is not “business data props”
|
||||
|
||||
@@ -214,36 +214,36 @@ Relay sometimes requires **opaque handles** on props: e.g. **`queryRef`** for `u
|
||||
|
||||
```tsx
|
||||
// Bad — parent only needed the param to pass it down
|
||||
function VendorLayout() {
|
||||
const { vendorId } = useParams();
|
||||
function ThirdPartyLayout() {
|
||||
const { thirdPartyId } = useParams();
|
||||
return (
|
||||
<main>
|
||||
<VendorSummary vendorId={vendorId!} />
|
||||
<ThirdPartySummary thirdPartyId={thirdPartyId!} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorSummary({ vendorId }: { vendorId: string }) {
|
||||
function ThirdPartySummary({ thirdPartyId }: { thirdPartyId: string }) {
|
||||
return <div>{/* … */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Good — component that needs the id reads it (or uses a dedicated hook)
|
||||
function VendorLayout() {
|
||||
function ThirdPartyLayout() {
|
||||
return (
|
||||
<main>
|
||||
<VendorSummary />
|
||||
<ThirdPartySummary />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorSummary() {
|
||||
const { vendorId } = useParams();
|
||||
if (vendorId == null) {
|
||||
function ThirdPartySummary() {
|
||||
const { thirdPartyId } = useParams();
|
||||
if (thirdPartyId == null) {
|
||||
return null;
|
||||
}
|
||||
return <div>{/* use vendorId in a hook / query … */}</div>;
|
||||
return <div>{/* use thirdPartyId in a hook / query … */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -251,13 +251,13 @@ function VendorSummary() {
|
||||
|
||||
```tsx
|
||||
// Bad — parent loaded data and passes fields as props
|
||||
function VendorPage() {
|
||||
const vendor = useLazyLoadQuery(/* … */);
|
||||
function ThirdPartyPage() {
|
||||
const thirdParty = useLazyLoadQuery(/* … */);
|
||||
return (
|
||||
<VendorHeader
|
||||
name={vendor.name}
|
||||
riskScore={vendor.riskScore}
|
||||
updatedAt={vendor.updatedAt}
|
||||
<ThirdPartyHeader
|
||||
name={thirdParty.name}
|
||||
riskScore={thirdParty.riskScore}
|
||||
updatedAt={thirdParty.updatedAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -265,24 +265,24 @@ function VendorPage() {
|
||||
|
||||
```tsx
|
||||
// Good — header colocates its fragment and reads via useFragment
|
||||
const vendorHeaderFragment = graphql`
|
||||
fragment VendorHeader_vendor on Vendor {
|
||||
const thirdPartyHeaderFragment = graphql`
|
||||
fragment ThirdPartyHeader_thirdParty on ThirdParty {
|
||||
name
|
||||
riskScore
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
interface VendorHeaderProps {
|
||||
interface ThirdPartyHeaderProps {
|
||||
className?: string;
|
||||
vendorKey: VendorHeader_vendor$key;
|
||||
thirdPartyKey: ThirdPartyHeader_thirdParty$key;
|
||||
}
|
||||
|
||||
export function VendorHeader({ className, vendorKey }: VendorHeaderProps) {
|
||||
const vendor = useFragment(vendorHeaderFragment, vendorKey);
|
||||
export function ThirdPartyHeader({ className, thirdPartyKey }: ThirdPartyHeaderProps) {
|
||||
const thirdParty = useFragment(thirdPartyHeaderFragment, thirdPartyKey);
|
||||
return (
|
||||
<header className={className}>
|
||||
{/* render from vendor … */}
|
||||
{/* render from thirdParty … */}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ Fragments colocate data requirements with the component that reads them:
|
||||
|
||||
```tsx
|
||||
const contactFragment = graphql`
|
||||
fragment ContactRow_contactFragment on VendorContact {
|
||||
fragment ContactRow_contactFragment on ThirdPartyContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
@@ -199,8 +199,8 @@ const contactFragment = graphql`
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
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")
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -215,12 +215,12 @@ function ContactRow(props: { contactKey: ContactRow_contactFragment$key }) {
|
||||
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
|
||||
|
||||
```tsx
|
||||
const vendorContactsFragment = graphql`
|
||||
fragment VendorContactsTabFragment on Vendor
|
||||
@refetchable(queryName: "VendorContactsListQuery")
|
||||
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 }
|
||||
@@ -231,18 +231,18 @@ const vendorContactsFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "VendorContactsTabFragment_contacts") {
|
||||
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
...VendorContactsTabFragment_contact
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
|
||||
const [data, refetch] = useRefetchableFragment(thirdPartyContactsFragment, thirdParty);
|
||||
const connectionId = data.contacts.__id;
|
||||
```
|
||||
|
||||
@@ -251,9 +251,9 @@ const connectionId = data.contacts.__id;
|
||||
Use `usePaginationFragment` for cursor-based Relay pagination:
|
||||
|
||||
```tsx
|
||||
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
|
||||
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.vendors.__id;
|
||||
const pagination = usePaginationFragment(paginatedThirdPartiesFragment, data.node);
|
||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.thirdParties.__id;
|
||||
```
|
||||
|
||||
The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity.
|
||||
@@ -294,7 +294,7 @@ createCookieBanner({ variables: { ... } });
|
||||
#### Examples
|
||||
|
||||
```tsx
|
||||
const [deleteVendor] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
|
||||
const [deleteThirdParty] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
|
||||
```
|
||||
|
||||
For mutations with user feedback, combine with `useToast` and use `onCompleted`/`onError` callbacks:
|
||||
@@ -415,9 +415,9 @@ This is useful for dialogs, drawers, or other components rendered outside the su
|
||||
```tsx
|
||||
// Add new edge to a connection
|
||||
const createMutation = graphql`
|
||||
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge @prependEdge(connections: $connections) {
|
||||
mutation CreateThirdPartyMutation($input: CreateThirdPartyInput!, $connections: [ID!]!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
@@ -429,19 +429,19 @@ const createMutation = graphql`
|
||||
|
||||
// Remove an edge from a connection
|
||||
const deleteMutation = graphql`
|
||||
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
|
||||
deleteVendor(input: $input) {
|
||||
deletedVendorId @deleteEdge(connections: $connections)
|
||||
mutation DeleteThirdPartyMutation($input: DeleteThirdPartyInput!, $connections: [ID!]!) {
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Update in-place (Relay matches by id — no directive needed)
|
||||
const updateMutation = graphql`
|
||||
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
|
||||
updateVendorContact(input: $input) {
|
||||
vendorContact {
|
||||
...VendorContactsTabFragment_contact
|
||||
mutation UpdateContactMutation($input: UpdateThirdPartyContactInput!) {
|
||||
updateThirdPartyContact(input: $input) {
|
||||
thirdPartyContact {
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,15 +505,15 @@ Destructive mutations (delete) are wrapped with a confirmation dialog:
|
||||
|
||||
```tsx
|
||||
const confirm = useConfirm();
|
||||
const [deleteVendor] = useMutation<DeleteVendorMutation>(deleteVendorMutation);
|
||||
const [deleteThirdParty] = useMutation<DeleteThirdPartyMutation>(deleteThirdPartyMutation);
|
||||
|
||||
return () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
deleteVendor({
|
||||
deleteThirdParty({
|
||||
variables: {
|
||||
input: { vendorId: vendor.id! },
|
||||
input: { thirdPartyId: thirdParty.id! },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted() {
|
||||
@@ -534,14 +534,14 @@ return () => {
|
||||
GraphQL operations are colocated with the components that use them. See [`contrib/claude/app-arborescence.md`](app-arborescence.md) for the full folder layout.
|
||||
|
||||
```
|
||||
pages/organizations/vendors/
|
||||
VendorsPage.tsx # query + pagination fragment
|
||||
pages/organizations/third-parties/
|
||||
ThirdPartiesPage.tsx # query + pagination fragment
|
||||
_components/
|
||||
CreateContactDialog.tsx # create mutation
|
||||
EditContactDialog.tsx # update mutation
|
||||
tabs/
|
||||
VendorContactsTab.tsx # refetchable fragment + item fragment
|
||||
VendorComplianceTab.tsx
|
||||
ThirdPartyContactsTab.tsx # refetchable fragment + item fragment
|
||||
ThirdPartyComplianceTab.tsx
|
||||
```
|
||||
|
||||
Component-specific operations (queries, fragments, mutations) are defined inline in the component file that uses them. Shared sub-components live in `_components/` next to the page (scoped to the nearest common ancestor).
|
||||
@@ -7,7 +7,7 @@ Custom fluent validation API in `pkg/validator/`. Used in every service method t
|
||||
Create a validator, chain `Check()` calls for each field, then call `Error()` to get accumulated errors:
|
||||
|
||||
```go
|
||||
func (req *CreateVendorRequest) Validate() error {
|
||||
func (req *CreateThirdPartyRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.OrganizationID, "organization_id",
|
||||
@@ -19,7 +19,7 @@ func (req *CreateVendorRequest) Validate() error {
|
||||
validator.SafeTextNoNewLine(TitleMaxLength),
|
||||
)
|
||||
v.Check(req.Category, "category",
|
||||
validator.OneOfSlice(coredata.VendorCategories()),
|
||||
validator.OneOfSlice(coredata.ThirdPartyCategories()),
|
||||
)
|
||||
|
||||
return v.Error()
|
||||
@@ -81,7 +81,7 @@ v.CheckEach(ids, "ids", func(index int, item any) {
|
||||
gidValue := item.(gid.GID)
|
||||
v.Check(gidValue, fmt.Sprintf("ids[%d]", index),
|
||||
validator.Required(),
|
||||
validator.GID(coredata.VendorEntityType),
|
||||
validator.GID(coredata.ThirdPartyEntityType),
|
||||
)
|
||||
})
|
||||
```
|
||||
@@ -135,7 +135,7 @@ Validation errors flow naturally through Go's error interface:
|
||||
3. GraphQL/HTTP handlers convert `ValidationErrors` to appropriate response format
|
||||
|
||||
```go
|
||||
func (s *Service) CreateVendor(ctx context.Context, req CreateVendorRequest) (*coredata.Vendor, error) {
|
||||
func (s *Service) CreateThirdParty(ctx context.Context, req CreateThirdPartyRequest) (*coredata.ThirdParty, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ create_risk \
|
||||
SECURITY MITIGATED 1 5
|
||||
|
||||
create_risk \
|
||||
"Third-party SaaS vendor data breach" \
|
||||
"Third-party SaaS data breach" \
|
||||
OPERATIONAL TRANSFERRED 3 4
|
||||
create_risk \
|
||||
"Cloud region outage causing service disruption" \
|
||||
@@ -514,7 +514,7 @@ create_risk \
|
||||
"Breach notification deadline missed" \
|
||||
COMPLIANCE MITIGATED 1 5
|
||||
create_risk \
|
||||
"Inadequate data processing agreements with vendors" \
|
||||
"Inadequate data processing agreements with third parties" \
|
||||
COMPLIANCE MITIGATED 3 3
|
||||
create_risk \
|
||||
"Employee data retained beyond legal period" \
|
||||
@@ -553,17 +553,17 @@ create_risk \
|
||||
|
||||
echo " 35 risks created"
|
||||
|
||||
echo " Creating vendors..."
|
||||
echo " Creating third parties..."
|
||||
|
||||
create_vendor() {
|
||||
create_third_party() {
|
||||
local name="$1"
|
||||
local description="$2"
|
||||
|
||||
local resp
|
||||
resp=$(prb_api "createVendor: $name" '
|
||||
mutation($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
resp=$(prb_api "createThirdParty: $name" '
|
||||
mutation($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
@@ -574,55 +574,55 @@ create_vendor() {
|
||||
description="$description" \
|
||||
)")
|
||||
local id
|
||||
id=$(echo "$resp" | jq -r '.data.createVendor.vendorEdge.node.id // empty')
|
||||
id=$(echo "$resp" | jq -r '.data.createThirdParty.thirdPartyEdge.node.id // empty')
|
||||
if [ -z "$id" ]; then
|
||||
echo "ERROR (createVendor: $name): no vendor id in response" >&2
|
||||
echo "ERROR (createThirdParty: $name): no third party id in response" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_vendor "Amazon Web Services" \
|
||||
create_third_party "Amazon Web Services" \
|
||||
"Cloud infrastructure and compute"
|
||||
create_vendor "Google Cloud Platform" \
|
||||
create_third_party "Google Cloud Platform" \
|
||||
"BigQuery analytics and AI services"
|
||||
create_vendor "Google Workspace" \
|
||||
create_third_party "Google Workspace" \
|
||||
"Email, calendar, and productivity suite"
|
||||
create_vendor "Microsoft 365" \
|
||||
create_third_party "Microsoft 365" \
|
||||
"Office productivity and collaboration"
|
||||
create_vendor "Datadog" \
|
||||
create_third_party "Datadog" \
|
||||
"Application monitoring and observability"
|
||||
create_vendor "PagerDuty" \
|
||||
create_third_party "PagerDuty" \
|
||||
"Incident management and on-call scheduling"
|
||||
create_vendor "Slack" \
|
||||
create_third_party "Slack" \
|
||||
"Team communication and messaging"
|
||||
create_vendor "GitHub" \
|
||||
create_third_party "GitHub" \
|
||||
"Source code management and CI/CD"
|
||||
create_vendor "Stripe" \
|
||||
create_third_party "Stripe" \
|
||||
"Payment processing and billing"
|
||||
create_vendor "Salesforce" \
|
||||
create_third_party "Salesforce" \
|
||||
"Customer relationship management"
|
||||
create_vendor "HubSpot" \
|
||||
create_third_party "HubSpot" \
|
||||
"Marketing automation and CRM"
|
||||
create_vendor "Notion" \
|
||||
create_third_party "Notion" \
|
||||
"Documentation and knowledge management"
|
||||
create_vendor "1Password" \
|
||||
create_third_party "1Password" \
|
||||
"Enterprise password management"
|
||||
create_vendor "Okta" \
|
||||
create_third_party "Okta" \
|
||||
"Identity and access management"
|
||||
create_vendor "CrowdStrike" \
|
||||
create_third_party "CrowdStrike" \
|
||||
"Endpoint protection and threat intelligence"
|
||||
create_vendor "Vanta" \
|
||||
create_third_party "Vanta" \
|
||||
"Compliance automation and monitoring"
|
||||
create_vendor "Jira" \
|
||||
create_third_party "Jira" \
|
||||
"Project management and issue tracking"
|
||||
create_vendor "Cloudflare" \
|
||||
create_third_party "Cloudflare" \
|
||||
"CDN, DNS, and DDoS protection"
|
||||
create_vendor "Twilio SendGrid" \
|
||||
create_third_party "Twilio SendGrid" \
|
||||
"Transactional email delivery"
|
||||
create_vendor "Snowflake" \
|
||||
create_third_party "Snowflake" \
|
||||
"Cloud data warehouse"
|
||||
|
||||
echo " 20 vendors created"
|
||||
echo " 20 third parties created"
|
||||
|
||||
echo " Creating measures..."
|
||||
|
||||
@@ -696,7 +696,7 @@ echo ""
|
||||
echo " Created:"
|
||||
echo " 3 frameworks, 43 controls"
|
||||
echo " 35 risks"
|
||||
echo " 20 vendors"
|
||||
echo " 20 third parties"
|
||||
echo " 15 measures"
|
||||
echo " 8 people"
|
||||
echo ""
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestAuditLog_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor to generate an audit log entry.
|
||||
factory.NewVendor(owner).WithName(factory.SafeName("AuditVendor")).Create()
|
||||
// Create a thirdParty to generate an audit log entry.
|
||||
factory.NewThirdParty(owner).WithName(factory.SafeName("AuditThirdParty")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
@@ -78,20 +78,20 @@ func TestAuditLog_List(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
|
||||
// Find the vendor create entry.
|
||||
// Find the thirdParty create entry.
|
||||
found := false
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
if edge.Node.Action == "core:vendor:create" {
|
||||
if edge.Node.Action == "core:thirdParty:create" {
|
||||
found = true
|
||||
assert.Equal(t, "USER", edge.Node.ActorType)
|
||||
assert.Equal(t, "Vendor", edge.Node.ResourceType)
|
||||
assert.Equal(t, "ThirdParty", edge.Node.ResourceType)
|
||||
assert.NotEmpty(t, edge.Node.ActorID)
|
||||
assert.NotEmpty(t, edge.Node.ResourceID)
|
||||
assert.NotEmpty(t, edge.Node.CreatedAt)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected to find core:vendor:create audit log entry")
|
||||
assert.True(t, found, "expected to find core:thirdParty:create audit log entry")
|
||||
}
|
||||
|
||||
func TestAuditLog_Filter(t *testing.T) {
|
||||
@@ -99,7 +99,7 @@ func TestAuditLog_Filter(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create different resources to generate different audit log entries.
|
||||
factory.NewVendor(owner).WithName(factory.SafeName("FilterVendor")).Create()
|
||||
factory.NewThirdParty(owner).WithName(factory.SafeName("FilterThirdParty")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!, $filter: AuditLogEntryFilter) {
|
||||
@@ -139,12 +139,12 @@ func TestAuditLog_Filter(t *testing.T) {
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"orgId": owner.GetOrganizationID().String(),
|
||||
"filter": map[string]any{"action": "core:vendor:create"},
|
||||
"filter": map[string]any{"action": "core:thirdParty:create"},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
assert.Equal(t, "core:vendor:create", edge.Node.Action)
|
||||
assert.Equal(t, "core:thirdParty:create", edge.Node.Action)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -167,12 +167,12 @@ func TestAuditLog_Filter(t *testing.T) {
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"orgId": owner.GetOrganizationID().String(),
|
||||
"filter": map[string]any{"resourceType": "Vendor"},
|
||||
"filter": map[string]any{"resourceType": "ThirdParty"},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
assert.Equal(t, "Vendor", edge.Node.ResourceType)
|
||||
assert.Equal(t, "ThirdParty", edge.Node.ResourceType)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func TestAuditLog_RBAC(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Generate an audit log entry.
|
||||
factory.NewVendor(owner).WithName(factory.SafeName("RBACVendor")).Create()
|
||||
factory.NewThirdParty(owner).WithName(factory.SafeName("RBACThirdParty")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
@@ -253,8 +253,8 @@ func TestAuditLog_TenantIsolation(t *testing.T) {
|
||||
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor in org1 to generate audit log entries.
|
||||
factory.NewVendor(org1Owner).WithName(factory.SafeName("IsoVendor")).Create()
|
||||
// Create a thirdParty in org1 to generate audit log entries.
|
||||
factory.NewThirdParty(org1Owner).WithName(factory.SafeName("IsoThirdParty")).Create()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
@@ -275,7 +275,7 @@ func TestAuditLog_TenantIsolation(t *testing.T) {
|
||||
}
|
||||
`
|
||||
|
||||
// org2 should not see org1's audit log entries about vendors.
|
||||
// org2 should not see org1's audit log entries about thirdParties.
|
||||
var result struct {
|
||||
Node struct {
|
||||
AuditLogEntries struct {
|
||||
@@ -298,9 +298,9 @@ func TestAuditLog_TenantIsolation(t *testing.T) {
|
||||
|
||||
for _, edge := range result.Node.AuditLogEntries.Edges {
|
||||
// org2 may have its own audit log entries (from user/org creation),
|
||||
// but should never see org1's vendor entries.
|
||||
if edge.Node.ResourceType == "Vendor" {
|
||||
t.Fatalf("org2 should not see org1's vendor audit log entries, but found: %s", edge.Node.Action)
|
||||
// but should never see org1's thirdParty entries.
|
||||
if edge.Node.ResourceType == "ThirdParty" {
|
||||
t.Fatalf("org2 should not see org1's thirdParty audit log entries, but found: %s", edge.Node.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,32 +174,32 @@ const (
|
||||
}
|
||||
}`
|
||||
|
||||
createVendorMutation = `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge { node { id } }
|
||||
createThirdPartyMutation = `
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge { node { id } }
|
||||
}
|
||||
}`
|
||||
|
||||
updateVendorMutation = `
|
||||
mutation UpdateVendor($input: UpdateVendorInput!) {
|
||||
updateVendor(input: $input) {
|
||||
vendor { id }
|
||||
updateThirdPartyMutation = `
|
||||
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty { id }
|
||||
}
|
||||
}`
|
||||
|
||||
deleteVendorMutation = `
|
||||
mutation DeleteVendor($input: DeleteVendorInput!) {
|
||||
deleteVendor(input: $input) {
|
||||
deletedVendorId
|
||||
deleteThirdPartyMutation = `
|
||||
mutation DeleteThirdParty($input: DeleteThirdPartyInput!) {
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId
|
||||
}
|
||||
}`
|
||||
|
||||
listVendorsQuery = `
|
||||
query GetVendors($id: ID!) {
|
||||
listThirdPartiesQuery = `
|
||||
query GetThirdParties($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Organization {
|
||||
vendors(first: 10) { totalCount }
|
||||
thirdParties(first: 10) { totalCount }
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -304,7 +304,7 @@ func TestRBAC(t *testing.T) {
|
||||
measureID := factory.NewMeasure(owner).WithName("RBAC Test Measure").Create()
|
||||
taskID := factory.NewTask(owner, measureID).WithName("RBAC Test Task").Create()
|
||||
riskID := factory.NewRisk(owner).WithName("RBAC Test Risk").Create()
|
||||
vendorID := factory.NewVendor(owner).WithName("RBAC Test Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("RBAC Test ThirdParty").Create()
|
||||
accessSourceID := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Source").Create()
|
||||
accessReviewCampaignID := factory.NewAccessReviewCampaign(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Campaign").Create()
|
||||
|
||||
@@ -936,123 +936,123 @@ func TestRBAC(t *testing.T) {
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "owner can create vendor",
|
||||
name: "owner can create thirdParty",
|
||||
role: "owner",
|
||||
client: owner,
|
||||
query: createVendorMutation,
|
||||
query: createThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("Vendor")}}
|
||||
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("ThirdParty")}}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "admin can create vendor",
|
||||
name: "admin can create thirdParty",
|
||||
role: "admin",
|
||||
client: admin,
|
||||
query: createVendorMutation,
|
||||
query: createThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("Vendor")}}
|
||||
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("ThirdParty")}}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "viewer cannot create vendor",
|
||||
name: "viewer cannot create thirdParty",
|
||||
role: "viewer",
|
||||
client: viewer,
|
||||
query: createVendorMutation,
|
||||
query: createThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("Vendor")}}
|
||||
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("ThirdParty")}}
|
||||
},
|
||||
shouldAllow: false,
|
||||
},
|
||||
{
|
||||
name: "owner can update vendor",
|
||||
name: "owner can update thirdParty",
|
||||
role: "owner",
|
||||
client: owner,
|
||||
query: updateVendorMutation,
|
||||
query: updateThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"input": map[string]any{"id": vendorID, "name": factory.SafeName("Updated Vendor")}}
|
||||
return map[string]any{"input": map[string]any{"id": thirdPartyID, "name": factory.SafeName("Updated ThirdParty")}}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "admin can update vendor",
|
||||
name: "admin can update thirdParty",
|
||||
role: "admin",
|
||||
client: admin,
|
||||
query: updateVendorMutation,
|
||||
query: updateThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"input": map[string]any{"id": vendorID, "name": factory.SafeName("Updated Vendor")}}
|
||||
return map[string]any{"input": map[string]any{"id": thirdPartyID, "name": factory.SafeName("Updated ThirdParty")}}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "viewer cannot update vendor",
|
||||
name: "viewer cannot update thirdParty",
|
||||
role: "viewer",
|
||||
client: viewer,
|
||||
query: updateVendorMutation,
|
||||
query: updateThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"input": map[string]any{"id": vendorID, "name": factory.SafeName("Updated Vendor")}}
|
||||
return map[string]any{"input": map[string]any{"id": thirdPartyID, "name": factory.SafeName("Updated ThirdParty")}}
|
||||
},
|
||||
shouldAllow: false,
|
||||
},
|
||||
{
|
||||
name: "owner can delete vendor",
|
||||
name: "owner can delete thirdParty",
|
||||
role: "owner",
|
||||
client: owner,
|
||||
query: deleteVendorMutation,
|
||||
query: deleteThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
id := factory.NewVendor(owner).WithName(factory.SafeName("ToDelete")).Create()
|
||||
return map[string]any{"input": map[string]any{"vendorId": id}}
|
||||
id := factory.NewThirdParty(owner).WithName(factory.SafeName("ToDelete")).Create()
|
||||
return map[string]any{"input": map[string]any{"thirdPartyId": id}}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "admin can delete vendor",
|
||||
name: "admin can delete thirdParty",
|
||||
role: "admin",
|
||||
client: admin,
|
||||
query: deleteVendorMutation,
|
||||
query: deleteThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
id := factory.NewVendor(owner).WithName(factory.SafeName("ToDelete")).Create()
|
||||
return map[string]any{"input": map[string]any{"vendorId": id}}
|
||||
id := factory.NewThirdParty(owner).WithName(factory.SafeName("ToDelete")).Create()
|
||||
return map[string]any{"input": map[string]any{"thirdPartyId": id}}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "viewer cannot delete vendor",
|
||||
name: "viewer cannot delete thirdParty",
|
||||
role: "viewer",
|
||||
client: viewer,
|
||||
query: deleteVendorMutation,
|
||||
query: deleteThirdPartyMutation,
|
||||
variables: func() map[string]any {
|
||||
id := factory.NewVendor(owner).WithName(factory.SafeName("ToDelete")).Create()
|
||||
return map[string]any{"input": map[string]any{"vendorId": id}}
|
||||
id := factory.NewThirdParty(owner).WithName(factory.SafeName("ToDelete")).Create()
|
||||
return map[string]any{"input": map[string]any{"thirdPartyId": id}}
|
||||
},
|
||||
shouldAllow: false,
|
||||
},
|
||||
{
|
||||
name: "owner can list vendors",
|
||||
name: "owner can list third parties",
|
||||
role: "owner",
|
||||
client: owner,
|
||||
query: listVendorsQuery,
|
||||
query: listThirdPartiesQuery,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"id": owner.GetOrganizationID().String()}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "admin can list vendors",
|
||||
name: "admin can list third parties",
|
||||
role: "admin",
|
||||
client: admin,
|
||||
query: listVendorsQuery,
|
||||
query: listThirdPartiesQuery,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"id": owner.GetOrganizationID().String()}
|
||||
},
|
||||
shouldAllow: true,
|
||||
},
|
||||
{
|
||||
name: "viewer can list vendors",
|
||||
name: "viewer can list third parties",
|
||||
role: "viewer",
|
||||
client: viewer,
|
||||
query: listVendorsQuery,
|
||||
query: listThirdPartiesQuery,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{"id": owner.GetOrganizationID().String()}
|
||||
},
|
||||
|
||||
@@ -23,15 +23,15 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestVendorComplianceReport_Upload(t *testing.T) {
|
||||
func TestThirdPartyComplianceReport_Upload(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
vendorID := factory.NewVendor(owner).WithName("Compliance Report Upload Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Compliance Report Upload ThirdParty").Create()
|
||||
|
||||
const query = `
|
||||
mutation UploadVendorComplianceReport($input: UploadVendorComplianceReportInput!) {
|
||||
uploadVendorComplianceReport(input: $input) {
|
||||
vendorComplianceReportEdge {
|
||||
mutation UploadThirdPartyComplianceReport($input: UploadThirdPartyComplianceReportInput!) {
|
||||
uploadThirdPartyComplianceReport(input: $input) {
|
||||
thirdPartyComplianceReportEdge {
|
||||
node {
|
||||
id
|
||||
reportName
|
||||
@@ -46,27 +46,27 @@ func TestVendorComplianceReport_Upload(t *testing.T) {
|
||||
pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF")
|
||||
|
||||
var result struct {
|
||||
UploadVendorComplianceReport struct {
|
||||
VendorComplianceReportEdge struct {
|
||||
UploadThirdPartyComplianceReport struct {
|
||||
ThirdPartyComplianceReportEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
ReportName string `json:"reportName"`
|
||||
ReportDate string `json:"reportDate"`
|
||||
ValidUntil *string `json:"validUntil"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorComplianceReportEdge"`
|
||||
} `json:"uploadVendorComplianceReport"`
|
||||
} `json:"thirdPartyComplianceReportEdge"`
|
||||
} `json:"uploadThirdPartyComplianceReport"`
|
||||
}
|
||||
|
||||
err := owner.ExecuteWithFile(
|
||||
query,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"reportName": "SOC 2 Type II",
|
||||
"reportDate": "2024-01-01T00:00:00Z",
|
||||
"validUntil": "2025-01-01T00:00:00Z",
|
||||
"file": nil,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"reportName": "SOC 2 Type II",
|
||||
"reportDate": "2024-01-01T00:00:00Z",
|
||||
"validUntil": "2025-01-01T00:00:00Z",
|
||||
"file": nil,
|
||||
},
|
||||
}, "input.file", testutil.UploadFile{
|
||||
Filename: "soc2-report.pdf",
|
||||
@@ -77,23 +77,23 @@ func TestVendorComplianceReport_Upload(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
node := result.UploadVendorComplianceReport.VendorComplianceReportEdge.Node
|
||||
node := result.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node
|
||||
assert.NotEmpty(t, node.ID)
|
||||
assert.Equal(t, "SOC 2 Type II", node.ReportName)
|
||||
assert.NotEmpty(t, node.ReportDate)
|
||||
assert.NotNil(t, node.ValidUntil)
|
||||
}
|
||||
func TestVendorComplianceReport_List(t *testing.T) {
|
||||
func TestThirdPartyComplianceReport_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
vendorID := factory.NewVendor(owner).WithName("Compliance Report List Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Compliance Report List ThirdParty").Create()
|
||||
|
||||
pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF")
|
||||
|
||||
uploadQuery := `
|
||||
mutation UploadVendorComplianceReport($input: UploadVendorComplianceReportInput!) {
|
||||
uploadVendorComplianceReport(input: $input) {
|
||||
vendorComplianceReportEdge {
|
||||
mutation UploadThirdPartyComplianceReport($input: UploadThirdPartyComplianceReportInput!) {
|
||||
uploadThirdPartyComplianceReport(input: $input) {
|
||||
thirdPartyComplianceReportEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
@@ -101,23 +101,23 @@ func TestVendorComplianceReport_List(t *testing.T) {
|
||||
`
|
||||
|
||||
var uploadResult struct {
|
||||
UploadVendorComplianceReport struct {
|
||||
VendorComplianceReportEdge struct {
|
||||
UploadThirdPartyComplianceReport struct {
|
||||
ThirdPartyComplianceReportEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorComplianceReportEdge"`
|
||||
} `json:"uploadVendorComplianceReport"`
|
||||
} `json:"thirdPartyComplianceReportEdge"`
|
||||
} `json:"uploadThirdPartyComplianceReport"`
|
||||
}
|
||||
|
||||
err := owner.ExecuteWithFile(
|
||||
uploadQuery,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"reportName": "ISO 27001",
|
||||
"reportDate": "2024-06-01T00:00:00Z",
|
||||
"file": nil,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"reportName": "ISO 27001",
|
||||
"reportDate": "2024-06-01T00:00:00Z",
|
||||
"file": nil,
|
||||
},
|
||||
}, "input.file", testutil.UploadFile{
|
||||
Filename: "iso27001.pdf",
|
||||
@@ -128,13 +128,13 @@ func TestVendorComplianceReport_List(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
reportID := uploadResult.UploadVendorComplianceReport.VendorComplianceReportEdge.Node.ID
|
||||
reportID := uploadResult.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node.ID
|
||||
require.NotEmpty(t, reportID)
|
||||
|
||||
const listQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Vendor {
|
||||
... on ThirdParty {
|
||||
id
|
||||
complianceReports(first: 10) {
|
||||
edges {
|
||||
@@ -165,7 +165,7 @@ func TestVendorComplianceReport_List(t *testing.T) {
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(listQuery, map[string]any{"id": vendorID}, &listResult)
|
||||
err = owner.Execute(listQuery, map[string]any{"id": thirdPartyID}, &listResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, listResult.Node.ComplianceReports.Edges, 1)
|
||||
@@ -173,17 +173,17 @@ func TestVendorComplianceReport_List(t *testing.T) {
|
||||
assert.Equal(t, "ISO 27001", listResult.Node.ComplianceReports.Edges[0].Node.ReportName)
|
||||
}
|
||||
|
||||
func TestVendorComplianceReport_Delete(t *testing.T) {
|
||||
func TestThirdPartyComplianceReport_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
vendorID := factory.NewVendor(owner).WithName("Compliance Report Delete Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Compliance Report Delete ThirdParty").Create()
|
||||
|
||||
pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF")
|
||||
|
||||
uploadQuery := `
|
||||
mutation UploadVendorComplianceReport($input: UploadVendorComplianceReportInput!) {
|
||||
uploadVendorComplianceReport(input: $input) {
|
||||
vendorComplianceReportEdge {
|
||||
mutation UploadThirdPartyComplianceReport($input: UploadThirdPartyComplianceReportInput!) {
|
||||
uploadThirdPartyComplianceReport(input: $input) {
|
||||
thirdPartyComplianceReportEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
@@ -191,21 +191,21 @@ func TestVendorComplianceReport_Delete(t *testing.T) {
|
||||
`
|
||||
|
||||
var uploadResult struct {
|
||||
UploadVendorComplianceReport struct {
|
||||
VendorComplianceReportEdge struct {
|
||||
UploadThirdPartyComplianceReport struct {
|
||||
ThirdPartyComplianceReportEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorComplianceReportEdge"`
|
||||
} `json:"uploadVendorComplianceReport"`
|
||||
} `json:"thirdPartyComplianceReportEdge"`
|
||||
} `json:"uploadThirdPartyComplianceReport"`
|
||||
}
|
||||
|
||||
err := owner.ExecuteWithFile(uploadQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"reportName": "PCI DSS",
|
||||
"reportDate": "2024-03-01T00:00:00Z",
|
||||
"file": nil,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"reportName": "PCI DSS",
|
||||
"reportDate": "2024-03-01T00:00:00Z",
|
||||
"file": nil,
|
||||
},
|
||||
}, "input.file", testutil.UploadFile{
|
||||
Filename: "pci-dss.pdf",
|
||||
@@ -214,21 +214,21 @@ func TestVendorComplianceReport_Delete(t *testing.T) {
|
||||
}, &uploadResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
reportID := uploadResult.UploadVendorComplianceReport.VendorComplianceReportEdge.Node.ID
|
||||
reportID := uploadResult.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node.ID
|
||||
require.NotEmpty(t, reportID)
|
||||
|
||||
const deleteQuery = `
|
||||
mutation DeleteVendorComplianceReport($input: DeleteVendorComplianceReportInput!) {
|
||||
deleteVendorComplianceReport(input: $input) {
|
||||
deletedVendorComplianceReportId
|
||||
mutation DeleteThirdPartyComplianceReport($input: DeleteThirdPartyComplianceReportInput!) {
|
||||
deleteThirdPartyComplianceReport(input: $input) {
|
||||
deletedThirdPartyComplianceReportId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var deleteResult struct {
|
||||
DeleteVendorComplianceReport struct {
|
||||
DeletedVendorComplianceReportID string `json:"deletedVendorComplianceReportId"`
|
||||
} `json:"deleteVendorComplianceReport"`
|
||||
DeleteThirdPartyComplianceReport struct {
|
||||
DeletedThirdPartyComplianceReportID string `json:"deletedThirdPartyComplianceReportId"`
|
||||
} `json:"deleteThirdPartyComplianceReport"`
|
||||
}
|
||||
|
||||
err = owner.Execute(
|
||||
@@ -241,5 +241,5 @@ func TestVendorComplianceReport_Delete(t *testing.T) {
|
||||
&deleteResult,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reportID, deleteResult.DeleteVendorComplianceReport.DeletedVendorComplianceReportID)
|
||||
assert.Equal(t, reportID, deleteResult.DeleteThirdPartyComplianceReport.DeletedThirdPartyComplianceReportID)
|
||||
}
|
||||
@@ -25,17 +25,17 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestVendorContact_Create(t *testing.T) {
|
||||
func TestThirdPartyContact_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor first
|
||||
vendorID := factory.NewVendor(owner).WithName("Contact Test Vendor").Create()
|
||||
// Create a thirdParty first
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Contact Test ThirdParty").Create()
|
||||
|
||||
query := `
|
||||
mutation CreateVendorContact($input: CreateVendorContactInput!) {
|
||||
createVendorContact(input: $input) {
|
||||
vendorContactEdge {
|
||||
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
|
||||
createThirdPartyContact(input: $input) {
|
||||
thirdPartyContactEdge {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
@@ -49,8 +49,8 @@ func TestVendorContact_Create(t *testing.T) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
CreateVendorContact struct {
|
||||
VendorContactEdge struct {
|
||||
CreateThirdPartyContact struct {
|
||||
ThirdPartyContactEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
@@ -58,39 +58,39 @@ func TestVendorContact_Create(t *testing.T) {
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorContactEdge"`
|
||||
} `json:"createVendorContact"`
|
||||
} `json:"thirdPartyContactEdge"`
|
||||
} `json:"createThirdPartyContact"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"fullName": "John Doe",
|
||||
"email": fmt.Sprintf("john.doe.%d@vendor.com", time.Now().UnixNano()),
|
||||
"phone": "+1-555-123-4567",
|
||||
"role": "Account Manager",
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"fullName": "John Doe",
|
||||
"email": fmt.Sprintf("john.doe.%d@thirdParty.com", time.Now().UnixNano()),
|
||||
"phone": "+1-555-123-4567",
|
||||
"role": "Account Manager",
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
contact := result.CreateVendorContact.VendorContactEdge.Node
|
||||
contact := result.CreateThirdPartyContact.ThirdPartyContactEdge.Node
|
||||
assert.NotEmpty(t, contact.ID)
|
||||
assert.Equal(t, "John Doe", contact.FullName)
|
||||
assert.Equal(t, "+1-555-123-4567", contact.Phone)
|
||||
assert.Equal(t, "Account Manager", contact.Role)
|
||||
}
|
||||
|
||||
func TestVendorContact_Update(t *testing.T) {
|
||||
func TestThirdPartyContact_Update(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor and contact
|
||||
vendorID := factory.NewVendor(owner).WithName("Update Contact Vendor").Create()
|
||||
// Create a thirdParty and contact
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Update Contact ThirdParty").Create()
|
||||
|
||||
createQuery := `
|
||||
mutation CreateVendorContact($input: CreateVendorContactInput!) {
|
||||
createVendorContact(input: $input) {
|
||||
vendorContactEdge {
|
||||
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
|
||||
createThirdPartyContact(input: $input) {
|
||||
thirdPartyContactEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -100,30 +100,30 @@ func TestVendorContact_Update(t *testing.T) {
|
||||
`
|
||||
|
||||
var createResult struct {
|
||||
CreateVendorContact struct {
|
||||
VendorContactEdge struct {
|
||||
CreateThirdPartyContact struct {
|
||||
ThirdPartyContactEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorContactEdge"`
|
||||
} `json:"createVendorContact"`
|
||||
} `json:"thirdPartyContactEdge"`
|
||||
} `json:"createThirdPartyContact"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"fullName": "Initial Name",
|
||||
"email": fmt.Sprintf("initial.%d@vendor.com", time.Now().UnixNano()),
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"fullName": "Initial Name",
|
||||
"email": fmt.Sprintf("initial.%d@thirdParty.com", time.Now().UnixNano()),
|
||||
},
|
||||
}, &createResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
contactID := createResult.CreateVendorContact.VendorContactEdge.Node.ID
|
||||
contactID := createResult.CreateThirdPartyContact.ThirdPartyContactEdge.Node.ID
|
||||
|
||||
query := `
|
||||
mutation UpdateVendorContact($input: UpdateVendorContactInput!) {
|
||||
updateVendorContact(input: $input) {
|
||||
vendorContact {
|
||||
mutation UpdateThirdPartyContact($input: UpdateThirdPartyContactInput!) {
|
||||
updateThirdPartyContact(input: $input) {
|
||||
thirdPartyContact {
|
||||
id
|
||||
fullName
|
||||
phone
|
||||
@@ -134,14 +134,14 @@ func TestVendorContact_Update(t *testing.T) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
UpdateVendorContact struct {
|
||||
VendorContact struct {
|
||||
UpdateThirdPartyContact struct {
|
||||
ThirdPartyContact struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
} `json:"vendorContact"`
|
||||
} `json:"updateVendorContact"`
|
||||
} `json:"thirdPartyContact"`
|
||||
} `json:"updateThirdPartyContact"`
|
||||
}
|
||||
|
||||
err = owner.Execute(query, map[string]any{
|
||||
@@ -154,23 +154,23 @@ func TestVendorContact_Update(t *testing.T) {
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, contactID, result.UpdateVendorContact.VendorContact.ID)
|
||||
assert.Equal(t, "Updated Name", result.UpdateVendorContact.VendorContact.FullName)
|
||||
assert.Equal(t, "+1-555-999-8888", result.UpdateVendorContact.VendorContact.Phone)
|
||||
assert.Equal(t, "Senior Account Manager", result.UpdateVendorContact.VendorContact.Role)
|
||||
assert.Equal(t, contactID, result.UpdateThirdPartyContact.ThirdPartyContact.ID)
|
||||
assert.Equal(t, "Updated Name", result.UpdateThirdPartyContact.ThirdPartyContact.FullName)
|
||||
assert.Equal(t, "+1-555-999-8888", result.UpdateThirdPartyContact.ThirdPartyContact.Phone)
|
||||
assert.Equal(t, "Senior Account Manager", result.UpdateThirdPartyContact.ThirdPartyContact.Role)
|
||||
}
|
||||
|
||||
func TestVendorContact_Delete(t *testing.T) {
|
||||
func TestThirdPartyContact_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
vendorID := factory.NewVendor(owner).WithName("Delete Contact Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("Delete Contact ThirdParty").Create()
|
||||
|
||||
// Create a contact to delete
|
||||
createQuery := `
|
||||
mutation CreateVendorContact($input: CreateVendorContactInput!) {
|
||||
createVendorContact(input: $input) {
|
||||
vendorContactEdge {
|
||||
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
|
||||
createThirdPartyContact(input: $input) {
|
||||
thirdPartyContactEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -180,61 +180,61 @@ func TestVendorContact_Delete(t *testing.T) {
|
||||
`
|
||||
|
||||
var createResult struct {
|
||||
CreateVendorContact struct {
|
||||
VendorContactEdge struct {
|
||||
CreateThirdPartyContact struct {
|
||||
ThirdPartyContactEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorContactEdge"`
|
||||
} `json:"createVendorContact"`
|
||||
} `json:"thirdPartyContactEdge"`
|
||||
} `json:"createThirdPartyContact"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"fullName": fmt.Sprintf("Contact to Delete %d", time.Now().UnixNano()),
|
||||
"email": fmt.Sprintf("delete.%d@vendor.com", time.Now().UnixNano()),
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"fullName": fmt.Sprintf("Contact to Delete %d", time.Now().UnixNano()),
|
||||
"email": fmt.Sprintf("delete.%d@thirdParty.com", time.Now().UnixNano()),
|
||||
},
|
||||
}, &createResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
contactID := createResult.CreateVendorContact.VendorContactEdge.Node.ID
|
||||
contactID := createResult.CreateThirdPartyContact.ThirdPartyContactEdge.Node.ID
|
||||
|
||||
deleteQuery := `
|
||||
mutation DeleteVendorContact($input: DeleteVendorContactInput!) {
|
||||
deleteVendorContact(input: $input) {
|
||||
deletedVendorContactId
|
||||
mutation DeleteThirdPartyContact($input: DeleteThirdPartyContactInput!) {
|
||||
deleteThirdPartyContact(input: $input) {
|
||||
deletedThirdPartyContactId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
DeleteVendorContact struct {
|
||||
DeletedVendorContactID string `json:"deletedVendorContactId"`
|
||||
} `json:"deleteVendorContact"`
|
||||
DeleteThirdPartyContact struct {
|
||||
DeletedThirdPartyContactID string `json:"deletedThirdPartyContactId"`
|
||||
} `json:"deleteThirdPartyContact"`
|
||||
}
|
||||
|
||||
err = owner.Execute(deleteQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorContactId": contactID,
|
||||
"thirdPartyContactId": contactID,
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, contactID, result.DeleteVendorContact.DeletedVendorContactID)
|
||||
assert.Equal(t, contactID, result.DeleteThirdPartyContact.DeletedThirdPartyContactID)
|
||||
}
|
||||
|
||||
func TestVendorContact_List(t *testing.T) {
|
||||
func TestThirdPartyContact_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
vendorID := factory.NewVendor(owner).WithName("List Contacts Vendor").Create()
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("List Contacts ThirdParty").Create()
|
||||
|
||||
// Create multiple contacts
|
||||
for i := range 3 {
|
||||
query := `
|
||||
mutation CreateVendorContact($input: CreateVendorContactInput!) {
|
||||
createVendorContact(input: $input) {
|
||||
vendorContactEdge {
|
||||
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
|
||||
createThirdPartyContact(input: $input) {
|
||||
thirdPartyContactEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -245,18 +245,18 @@ func TestVendorContact_List(t *testing.T) {
|
||||
|
||||
_, err := owner.Do(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"fullName": fmt.Sprintf("Contact %d", i),
|
||||
"email": fmt.Sprintf("contact.%d.%d@vendor.com", i, time.Now().UnixNano()),
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"fullName": fmt.Sprintf("Contact %d", i),
|
||||
"email": fmt.Sprintf("contact.%d.%d@thirdParty.com", i, time.Now().UnixNano()),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
query := `
|
||||
query GetVendorContacts($id: ID!) {
|
||||
query GetThirdPartyContacts($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Vendor {
|
||||
... on ThirdParty {
|
||||
contacts(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
@@ -286,7 +286,7 @@ func TestVendorContact_List(t *testing.T) {
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"id": vendorID,
|
||||
"id": thirdPartyID,
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(result.Node.Contacts.Edges), 3)
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestVendor_PublishVendorList(t *testing.T) {
|
||||
func TestThirdParty_PublishThirdPartyList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
@@ -32,11 +32,11 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
factory.CreateVendor(owner, factory.Attrs{"name": "Test Vendor"})
|
||||
factory.CreateThirdParty(owner, factory.Attrs{"name": "Test ThirdParty"})
|
||||
|
||||
const query = `
|
||||
mutation($input: PublishVendorListInput!) {
|
||||
publishVendorList(input: $input) {
|
||||
mutation($input: PublishThirdPartyListInput!) {
|
||||
publishThirdPartyList(input: $input) {
|
||||
documentEdge {
|
||||
node {
|
||||
id
|
||||
@@ -60,7 +60,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
PublishVendorList struct {
|
||||
PublishThirdPartyList struct {
|
||||
DocumentEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
@@ -79,7 +79,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
Content string `json:"content"`
|
||||
} `json:"node"`
|
||||
} `json:"documentVersionEdge"`
|
||||
} `json:"publishVendorList"`
|
||||
} `json:"publishThirdPartyList"`
|
||||
}
|
||||
|
||||
err := owner.Execute(
|
||||
@@ -94,12 +94,12 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
doc := result.PublishVendorList.DocumentEdge.Node
|
||||
doc := result.PublishThirdPartyList.DocumentEdge.Node
|
||||
assert.NotEmpty(t, doc.ID)
|
||||
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||
assert.Equal(t, "ACTIVE", doc.Status)
|
||||
|
||||
ver := result.PublishVendorList.DocumentVersionEdge.Node
|
||||
ver := result.PublishThirdPartyList.DocumentVersionEdge.Node
|
||||
assert.NotEmpty(t, ver.ID)
|
||||
assert.Equal(t, "REGISTER", ver.DocumentType)
|
||||
assert.Equal(t, "PUBLISHED", ver.Status)
|
||||
@@ -117,8 +117,8 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
const query = `
|
||||
mutation($input: PublishVendorListInput!) {
|
||||
publishVendorList(input: $input) {
|
||||
mutation($input: PublishThirdPartyListInput!) {
|
||||
publishThirdPartyList(input: $input) {
|
||||
documentEdge {
|
||||
node { id writeMode }
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
PublishVendorList struct {
|
||||
PublishThirdPartyList struct {
|
||||
DocumentEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
@@ -144,7 +144,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
Major int `json:"major"`
|
||||
} `json:"node"`
|
||||
} `json:"documentVersionEdge"`
|
||||
} `json:"publishVendorList"`
|
||||
} `json:"publishThirdPartyList"`
|
||||
}
|
||||
|
||||
err := owner.Execute(
|
||||
@@ -160,11 +160,11 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
doc := result.PublishVendorList.DocumentEdge.Node
|
||||
doc := result.PublishThirdPartyList.DocumentEdge.Node
|
||||
assert.NotEmpty(t, doc.ID)
|
||||
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||
|
||||
ver := result.PublishVendorList.DocumentVersionEdge.Node
|
||||
ver := result.PublishThirdPartyList.DocumentVersionEdge.Node
|
||||
assert.NotEmpty(t, ver.ID)
|
||||
assert.Equal(t, "PENDING_APPROVAL", ver.Status)
|
||||
},
|
||||
@@ -176,11 +176,11 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
factory.CreateVendor(owner, factory.Attrs{"name": "Reuse Vendor"})
|
||||
factory.CreateThirdParty(owner, factory.Attrs{"name": "Reuse ThirdParty"})
|
||||
|
||||
const query = `
|
||||
mutation($input: PublishVendorListInput!) {
|
||||
publishVendorList(input: $input) {
|
||||
mutation($input: PublishThirdPartyListInput!) {
|
||||
publishThirdPartyList(input: $input) {
|
||||
documentEdge { node { id } }
|
||||
documentVersionEdge { node { id major } }
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
`
|
||||
|
||||
var r1, r2 struct {
|
||||
PublishVendorList struct {
|
||||
PublishThirdPartyList struct {
|
||||
DocumentEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
@@ -200,7 +200,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
Major int `json:"major"`
|
||||
} `json:"node"`
|
||||
} `json:"documentVersionEdge"`
|
||||
} `json:"publishVendorList"`
|
||||
} `json:"publishThirdPartyList"`
|
||||
}
|
||||
|
||||
input := map[string]any{
|
||||
@@ -217,26 +217,26 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t,
|
||||
r1.PublishVendorList.DocumentEdge.Node.ID,
|
||||
r2.PublishVendorList.DocumentEdge.Node.ID,
|
||||
r1.PublishThirdPartyList.DocumentEdge.Node.ID,
|
||||
r2.PublishThirdPartyList.DocumentEdge.Node.ID,
|
||||
"should reuse same document",
|
||||
)
|
||||
assert.Equal(t, 1, r1.PublishVendorList.DocumentVersionEdge.Node.Major)
|
||||
assert.Equal(t, 2, r2.PublishVendorList.DocumentVersionEdge.Node.Major)
|
||||
assert.Equal(t, 1, r1.PublishThirdPartyList.DocumentVersionEdge.Node.Major)
|
||||
assert.Equal(t, 2, r2.PublishThirdPartyList.DocumentVersionEdge.Node.Major)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"organization vendorsDocument links to published document",
|
||||
"organization thirdPartiesDocument links to published document",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
factory.CreateVendor(owner, factory.Attrs{"name": "Linked Vendor"})
|
||||
factory.CreateThirdParty(owner, factory.Attrs{"name": "Linked ThirdParty"})
|
||||
|
||||
const publishQuery = `
|
||||
mutation($input: PublishVendorListInput!) {
|
||||
publishVendorList(input: $input) {
|
||||
mutation($input: PublishThirdPartyListInput!) {
|
||||
publishThirdPartyList(input: $input) {
|
||||
documentEdge { node { id } }
|
||||
documentVersionEdge { node { id } }
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
`
|
||||
|
||||
var publishResult struct {
|
||||
PublishVendorList struct {
|
||||
PublishThirdPartyList struct {
|
||||
DocumentEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
@@ -255,7 +255,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"documentVersionEdge"`
|
||||
} `json:"publishVendorList"`
|
||||
} `json:"publishThirdPartyList"`
|
||||
}
|
||||
|
||||
err := owner.Execute(
|
||||
@@ -270,14 +270,14 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
docID := publishResult.PublishVendorList.DocumentEdge.Node.ID
|
||||
docID := publishResult.PublishThirdPartyList.DocumentEdge.Node.ID
|
||||
|
||||
const orgQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Organization {
|
||||
id
|
||||
vendorsDocument { id }
|
||||
thirdPartiesDocument { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,10 +285,10 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
|
||||
var orgResult struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
VendorsDocument *struct {
|
||||
ID string `json:"id"`
|
||||
ThirdPartiesDocument *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"vendorsDocument"`
|
||||
} `json:"thirdPartiesDocument"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
@@ -298,23 +298,23 @@ func TestVendor_PublishVendorList(t *testing.T) {
|
||||
&orgResult,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, orgResult.Node.VendorsDocument)
|
||||
assert.Equal(t, docID, orgResult.Node.VendorsDocument.ID)
|
||||
require.NotNil(t, orgResult.Node.ThirdPartiesDocument)
|
||||
assert.Equal(t, docID, orgResult.Node.ThirdPartiesDocument.ID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestVendor_PublishVendorList_RBAC(t *testing.T) {
|
||||
func TestThirdParty_PublishThirdPartyList_RBAC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
|
||||
factory.CreateVendor(owner, factory.Attrs{"name": "RBAC Vendor"})
|
||||
factory.CreateThirdParty(owner, factory.Attrs{"name": "RBAC ThirdParty"})
|
||||
|
||||
const query = `
|
||||
mutation($input: PublishVendorListInput!) {
|
||||
publishVendorList(input: $input) {
|
||||
mutation($input: PublishThirdPartyListInput!) {
|
||||
publishThirdPartyList(input: $input) {
|
||||
documentEdge { node { id } }
|
||||
documentVersionEdge { node { id } }
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func TestVendor_PublishVendorList_RBAC(t *testing.T) {
|
||||
`
|
||||
|
||||
t.Run(
|
||||
"viewer cannot publish vendor list",
|
||||
"viewer cannot publish thirdParty list",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -22,15 +22,15 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestVendorService_Create(t *testing.T) {
|
||||
func TestThirdPartyService_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor first
|
||||
createVendorMutation := `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
// Create a thirdParty first
|
||||
createThirdPartyMutation := `
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -39,129 +39,129 @@ func TestVendorService_Create(t *testing.T) {
|
||||
}
|
||||
`
|
||||
|
||||
var createVendorResult struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
var createThirdPartyResult struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createVendorMutation, map[string]any{
|
||||
err := owner.Execute(createThirdPartyMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": "AWS",
|
||||
"category": "CLOUD_PROVIDER",
|
||||
},
|
||||
}, &createVendorResult)
|
||||
}, &createThirdPartyResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
|
||||
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
role testutil.TestRole
|
||||
variables func() map[string]any
|
||||
check func(t *testing.T, err error, m *struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
})
|
||||
}{
|
||||
{
|
||||
name: "Owner can create vendor service",
|
||||
name: "Owner can create thirdParty service",
|
||||
role: testutil.RoleOwner,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Amazon S3",
|
||||
"description": "Simple Storage Service",
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Amazon S3",
|
||||
"description": "Simple Storage Service",
|
||||
},
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, m *struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}) {
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, m.CreateVendorService.VendorServiceEdge.Node.ID)
|
||||
assert.Equal(t, "Amazon S3", m.CreateVendorService.VendorServiceEdge.Node.Name)
|
||||
assert.Equal(t, "Simple Storage Service", *m.CreateVendorService.VendorServiceEdge.Node.Description)
|
||||
assert.NotEmpty(t, m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID)
|
||||
assert.Equal(t, "Amazon S3", m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.Name)
|
||||
assert.Equal(t, "Simple Storage Service", *m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.Description)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Admin can create vendor service",
|
||||
name: "Admin can create thirdParty service",
|
||||
role: testutil.RoleAdmin,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Amazon EC2",
|
||||
"description": "Elastic Compute Cloud",
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Amazon EC2",
|
||||
"description": "Elastic Compute Cloud",
|
||||
},
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, m *struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}) {
|
||||
require.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Viewer cannot create vendor service",
|
||||
name: "Viewer cannot create thirdParty service",
|
||||
role: testutil.RoleViewer,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Should Fail",
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Should Fail",
|
||||
},
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, m *struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}) {
|
||||
require.Error(t, err, "Viewer should not be able to create vendor service")
|
||||
require.Error(t, err, "Viewer should not be able to create thirdParty service")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
createVendorServiceMutation := `
|
||||
mutation CreateVendorService($input: CreateVendorServiceInput!) {
|
||||
createVendorService(input: $input) {
|
||||
vendorServiceEdge {
|
||||
createThirdPartyServiceMutation := `
|
||||
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
|
||||
createThirdPartyService(input: $input) {
|
||||
thirdPartyServiceEdge {
|
||||
node {
|
||||
id
|
||||
name
|
||||
@@ -182,32 +182,32 @@ func TestVendorService_Create(t *testing.T) {
|
||||
}
|
||||
|
||||
var m struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}
|
||||
|
||||
err := client.Execute(createVendorServiceMutation, tt.variables(), &m)
|
||||
err := client.Execute(createThirdPartyServiceMutation, tt.variables(), &m)
|
||||
tt.check(t, err, &m)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorService_Update(t *testing.T) {
|
||||
func TestThirdPartyService_Update(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor first
|
||||
createVendorMutation := `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
// Create a thirdParty first
|
||||
createThirdPartyMutation := `
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -216,32 +216,32 @@ func TestVendorService_Update(t *testing.T) {
|
||||
}
|
||||
`
|
||||
|
||||
var createVendorResult struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
var createThirdPartyResult struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createVendorMutation, map[string]any{
|
||||
err := owner.Execute(createThirdPartyMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": "Google Cloud",
|
||||
"category": "CLOUD_PROVIDER",
|
||||
},
|
||||
}, &createVendorResult)
|
||||
}, &createThirdPartyResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
|
||||
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
|
||||
// Create a vendor service
|
||||
// Create a thirdParty service
|
||||
createServiceMutation := `
|
||||
mutation CreateVendorService($input: CreateVendorServiceInput!) {
|
||||
createVendorService(input: $input) {
|
||||
vendorServiceEdge {
|
||||
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
|
||||
createThirdPartyService(input: $input) {
|
||||
thirdPartyServiceEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -251,42 +251,42 @@ func TestVendorService_Update(t *testing.T) {
|
||||
`
|
||||
|
||||
var createServiceResult struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}
|
||||
|
||||
err = owner.Execute(createServiceMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Cloud Storage",
|
||||
"description": "Initial description",
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Cloud Storage",
|
||||
"description": "Initial description",
|
||||
},
|
||||
}, &createServiceResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceID := createServiceResult.CreateVendorService.VendorServiceEdge.Node.ID
|
||||
serviceID := createServiceResult.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
role testutil.TestRole
|
||||
variables func() map[string]any
|
||||
check func(t *testing.T, err error, m *struct {
|
||||
UpdateVendorService struct {
|
||||
VendorService struct {
|
||||
UpdateThirdPartyService struct {
|
||||
ThirdPartyService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"vendorService"`
|
||||
} `json:"updateVendorService"`
|
||||
} `json:"thirdPartyService"`
|
||||
} `json:"updateThirdPartyService"`
|
||||
})
|
||||
}{
|
||||
{
|
||||
name: "Owner can update vendor service",
|
||||
name: "Owner can update thirdParty service",
|
||||
role: testutil.RoleOwner,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
@@ -298,22 +298,22 @@ func TestVendorService_Update(t *testing.T) {
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, m *struct {
|
||||
UpdateVendorService struct {
|
||||
VendorService struct {
|
||||
UpdateThirdPartyService struct {
|
||||
ThirdPartyService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"vendorService"`
|
||||
} `json:"updateVendorService"`
|
||||
} `json:"thirdPartyService"`
|
||||
} `json:"updateThirdPartyService"`
|
||||
}) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, serviceID, m.UpdateVendorService.VendorService.ID)
|
||||
assert.Equal(t, "Updated Cloud Storage", m.UpdateVendorService.VendorService.Name)
|
||||
assert.Equal(t, "Updated description", *m.UpdateVendorService.VendorService.Description)
|
||||
assert.Equal(t, serviceID, m.UpdateThirdPartyService.ThirdPartyService.ID)
|
||||
assert.Equal(t, "Updated Cloud Storage", m.UpdateThirdPartyService.ThirdPartyService.Name)
|
||||
assert.Equal(t, "Updated description", *m.UpdateThirdPartyService.ThirdPartyService.Description)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Admin can update vendor service",
|
||||
name: "Admin can update thirdParty service",
|
||||
role: testutil.RoleAdmin,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
@@ -324,19 +324,19 @@ func TestVendorService_Update(t *testing.T) {
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, m *struct {
|
||||
UpdateVendorService struct {
|
||||
VendorService struct {
|
||||
UpdateThirdPartyService struct {
|
||||
ThirdPartyService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"vendorService"`
|
||||
} `json:"updateVendorService"`
|
||||
} `json:"thirdPartyService"`
|
||||
} `json:"updateThirdPartyService"`
|
||||
}) {
|
||||
require.NoError(t, err)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Viewer cannot update vendor service",
|
||||
name: "Viewer cannot update thirdParty service",
|
||||
role: testutil.RoleViewer,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
@@ -347,23 +347,23 @@ func TestVendorService_Update(t *testing.T) {
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, m *struct {
|
||||
UpdateVendorService struct {
|
||||
VendorService struct {
|
||||
UpdateThirdPartyService struct {
|
||||
ThirdPartyService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"vendorService"`
|
||||
} `json:"updateVendorService"`
|
||||
} `json:"thirdPartyService"`
|
||||
} `json:"updateThirdPartyService"`
|
||||
}) {
|
||||
require.Error(t, err, "Viewer should not be able to update vendor service")
|
||||
require.Error(t, err, "Viewer should not be able to update thirdParty service")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updateVendorServiceMutation := `
|
||||
mutation UpdateVendorService($input: UpdateVendorServiceInput!) {
|
||||
updateVendorService(input: $input) {
|
||||
vendorService {
|
||||
updateThirdPartyServiceMutation := `
|
||||
mutation UpdateThirdPartyService($input: UpdateThirdPartyServiceInput!) {
|
||||
updateThirdPartyService(input: $input) {
|
||||
thirdPartyService {
|
||||
id
|
||||
name
|
||||
description
|
||||
@@ -382,30 +382,30 @@ func TestVendorService_Update(t *testing.T) {
|
||||
}
|
||||
|
||||
var m struct {
|
||||
UpdateVendorService struct {
|
||||
VendorService struct {
|
||||
UpdateThirdPartyService struct {
|
||||
ThirdPartyService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
} `json:"vendorService"`
|
||||
} `json:"updateVendorService"`
|
||||
} `json:"thirdPartyService"`
|
||||
} `json:"updateThirdPartyService"`
|
||||
}
|
||||
|
||||
err := client.Execute(updateVendorServiceMutation, tt.variables(), &m)
|
||||
err := client.Execute(updateThirdPartyServiceMutation, tt.variables(), &m)
|
||||
tt.check(t, err, &m)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorService_Delete(t *testing.T) {
|
||||
func TestThirdPartyService_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor first
|
||||
createVendorMutation := `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
// Create a thirdParty first
|
||||
createThirdPartyMutation := `
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -414,32 +414,32 @@ func TestVendorService_Delete(t *testing.T) {
|
||||
}
|
||||
`
|
||||
|
||||
var createVendorResult struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
var createThirdPartyResult struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createVendorMutation, map[string]any{
|
||||
err := owner.Execute(createThirdPartyMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": "Azure",
|
||||
"category": "CLOUD_PROVIDER",
|
||||
},
|
||||
}, &createVendorResult)
|
||||
}, &createThirdPartyResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
|
||||
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
|
||||
createService := func() string {
|
||||
createServiceMutation := `
|
||||
mutation CreateVendorService($input: CreateVendorServiceInput!) {
|
||||
createVendorService(input: $input) {
|
||||
vendorServiceEdge {
|
||||
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
|
||||
createThirdPartyService(input: $input) {
|
||||
thirdPartyServiceEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -449,24 +449,24 @@ func TestVendorService_Delete(t *testing.T) {
|
||||
`
|
||||
|
||||
var m struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createServiceMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Service to delete",
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Service to delete",
|
||||
},
|
||||
}, &m)
|
||||
require.NoError(t, err)
|
||||
|
||||
return m.CreateVendorService.VendorServiceEdge.Node.ID
|
||||
return m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -474,73 +474,73 @@ func TestVendorService_Delete(t *testing.T) {
|
||||
role testutil.TestRole
|
||||
variables func(serviceID string) map[string]any
|
||||
check func(t *testing.T, err error, serviceID string, m *struct {
|
||||
DeleteVendorService struct {
|
||||
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
|
||||
} `json:"deleteVendorService"`
|
||||
DeleteThirdPartyService struct {
|
||||
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
|
||||
} `json:"deleteThirdPartyService"`
|
||||
})
|
||||
}{
|
||||
{
|
||||
name: "Viewer cannot delete vendor service",
|
||||
name: "Viewer cannot delete thirdParty service",
|
||||
role: testutil.RoleViewer,
|
||||
variables: func(serviceID string) map[string]any {
|
||||
return map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorServiceId": serviceID,
|
||||
"thirdPartyServiceId": serviceID,
|
||||
},
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, serviceID string, m *struct {
|
||||
DeleteVendorService struct {
|
||||
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
|
||||
} `json:"deleteVendorService"`
|
||||
DeleteThirdPartyService struct {
|
||||
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
|
||||
} `json:"deleteThirdPartyService"`
|
||||
}) {
|
||||
require.Error(t, err, "Viewer should not be able to delete vendor service")
|
||||
require.Error(t, err, "Viewer should not be able to delete thirdParty service")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Admin can delete vendor service",
|
||||
name: "Admin can delete thirdParty service",
|
||||
role: testutil.RoleAdmin,
|
||||
variables: func(serviceID string) map[string]any {
|
||||
return map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorServiceId": serviceID,
|
||||
"thirdPartyServiceId": serviceID,
|
||||
},
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, serviceID string, m *struct {
|
||||
DeleteVendorService struct {
|
||||
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
|
||||
} `json:"deleteVendorService"`
|
||||
DeleteThirdPartyService struct {
|
||||
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
|
||||
} `json:"deleteThirdPartyService"`
|
||||
}) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, serviceID, m.DeleteVendorService.DeletedVendorServiceID)
|
||||
assert.Equal(t, serviceID, m.DeleteThirdPartyService.DeletedThirdPartyServiceID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Owner can delete vendor service",
|
||||
name: "Owner can delete thirdParty service",
|
||||
role: testutil.RoleOwner,
|
||||
variables: func(serviceID string) map[string]any {
|
||||
return map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorServiceId": serviceID,
|
||||
"thirdPartyServiceId": serviceID,
|
||||
},
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, serviceID string, m *struct {
|
||||
DeleteVendorService struct {
|
||||
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
|
||||
} `json:"deleteVendorService"`
|
||||
DeleteThirdPartyService struct {
|
||||
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
|
||||
} `json:"deleteThirdPartyService"`
|
||||
}) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, serviceID, m.DeleteVendorService.DeletedVendorServiceID)
|
||||
assert.Equal(t, serviceID, m.DeleteThirdPartyService.DeletedThirdPartyServiceID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
deleteVendorServiceMutation := `
|
||||
mutation DeleteVendorService($input: DeleteVendorServiceInput!) {
|
||||
deleteVendorService(input: $input) {
|
||||
deletedVendorServiceId
|
||||
deleteThirdPartyServiceMutation := `
|
||||
mutation DeleteThirdPartyService($input: DeleteThirdPartyServiceInput!) {
|
||||
deleteThirdPartyService(input: $input) {
|
||||
deletedThirdPartyServiceId
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -557,26 +557,26 @@ func TestVendorService_Delete(t *testing.T) {
|
||||
}
|
||||
|
||||
var m struct {
|
||||
DeleteVendorService struct {
|
||||
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
|
||||
} `json:"deleteVendorService"`
|
||||
DeleteThirdPartyService struct {
|
||||
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
|
||||
} `json:"deleteThirdPartyService"`
|
||||
}
|
||||
|
||||
err := client.Execute(deleteVendorServiceMutation, tt.variables(serviceID), &m)
|
||||
err := client.Execute(deleteThirdPartyServiceMutation, tt.variables(serviceID), &m)
|
||||
tt.check(t, err, serviceID, &m)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorService_List(t *testing.T) {
|
||||
func TestThirdPartyService_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a vendor first
|
||||
createVendorMutation := `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
// Create a thirdParty first
|
||||
createThirdPartyMutation := `
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -585,32 +585,32 @@ func TestVendorService_List(t *testing.T) {
|
||||
}
|
||||
`
|
||||
|
||||
var createVendorResult struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
var createThirdPartyResult struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createVendorMutation, map[string]any{
|
||||
err := owner.Execute(createThirdPartyMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": "Vendor for Services",
|
||||
"name": "ThirdParty for Services",
|
||||
"category": "CLOUD_PROVIDER",
|
||||
},
|
||||
}, &createVendorResult)
|
||||
}, &createThirdPartyResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
|
||||
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
|
||||
// Create multiple services
|
||||
createServiceMutation := `
|
||||
mutation CreateVendorService($input: CreateVendorServiceInput!) {
|
||||
createVendorService(input: $input) {
|
||||
vendorServiceEdge {
|
||||
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
|
||||
createThirdPartyService(input: $input) {
|
||||
thirdPartyServiceEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
@@ -622,19 +622,19 @@ func TestVendorService_List(t *testing.T) {
|
||||
services := []string{"Service A", "Service B", "Service C"}
|
||||
for _, name := range services {
|
||||
var m struct {
|
||||
CreateVendorService struct {
|
||||
VendorServiceEdge struct {
|
||||
CreateThirdPartyService struct {
|
||||
ThirdPartyServiceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorServiceEdge"`
|
||||
} `json:"createVendorService"`
|
||||
} `json:"thirdPartyServiceEdge"`
|
||||
} `json:"createThirdPartyService"`
|
||||
}
|
||||
|
||||
err := owner.Execute(createServiceMutation, map[string]any{
|
||||
"input": map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": name,
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": name,
|
||||
},
|
||||
}, &m)
|
||||
require.NoError(t, err)
|
||||
@@ -659,11 +659,11 @@ func TestVendorService_List(t *testing.T) {
|
||||
})
|
||||
}{
|
||||
{
|
||||
name: "Owner can list vendor services",
|
||||
name: "Owner can list thirdParty services",
|
||||
role: testutil.RoleOwner,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
"id": vendorID,
|
||||
"id": thirdPartyID,
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, q *struct {
|
||||
@@ -684,11 +684,11 @@ func TestVendorService_List(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Viewer can list vendor services",
|
||||
name: "Viewer can list thirdParty services",
|
||||
role: testutil.RoleViewer,
|
||||
variables: func() map[string]any {
|
||||
return map[string]any{
|
||||
"id": vendorID,
|
||||
"id": thirdPartyID,
|
||||
}
|
||||
},
|
||||
check: func(t *testing.T, err error, q *struct {
|
||||
@@ -709,10 +709,10 @@ func TestVendorService_List(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
listVendorServicesQuery := `
|
||||
query ListVendorServices($id: ID!) {
|
||||
listThirdPartyServicesQuery := `
|
||||
query ListThirdPartyServices($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Vendor {
|
||||
... on ThirdParty {
|
||||
id
|
||||
services(first: 10) {
|
||||
edges {
|
||||
@@ -750,7 +750,7 @@ func TestVendorService_List(t *testing.T) {
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := client.Execute(listVendorServicesQuery, tt.variables(), &q)
|
||||
err := client.Execute(listThirdPartyServicesQuery, tt.variables(), &q)
|
||||
tt.check(t, err, &q)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,7 +142,7 @@ func CreateUser(c *testutil.Client, attrs ...Attrs) string {
|
||||
return result.CreateUser.ProfileEdge.Node.ID
|
||||
}
|
||||
|
||||
func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
|
||||
func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
|
||||
c.T.Helper()
|
||||
|
||||
var a Attrs
|
||||
@@ -151,9 +151,9 @@ func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
|
||||
}
|
||||
|
||||
const query = `
|
||||
mutation($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
mutation($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
|
||||
|
||||
input := map[string]any{
|
||||
"organizationId": c.GetOrganizationID().String(),
|
||||
"name": a.getString("name", SafeName("Vendor")),
|
||||
"name": a.getString("name", SafeName("ThirdParty")),
|
||||
}
|
||||
if desc := a.getStringPtr("description"); desc != nil {
|
||||
input["description"] = *desc
|
||||
@@ -175,19 +175,19 @@ func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
|
||||
}
|
||||
|
||||
var result struct {
|
||||
CreateVendor struct {
|
||||
VendorEdge struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"vendorEdge"`
|
||||
} `json:"createVendor"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := c.Execute(query, map[string]any{"input": input}, &result)
|
||||
require.NoError(c.T, err, "createVendor mutation failed")
|
||||
require.NoError(c.T, err, "createThirdParty mutation failed")
|
||||
|
||||
return result.CreateVendor.VendorEdge.Node.ID
|
||||
return result.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
}
|
||||
|
||||
func CreateFramework(c *testutil.Client, attrs ...Attrs) string {
|
||||
@@ -414,37 +414,37 @@ func CreateRisk(c *testutil.Client, attrs ...Attrs) string {
|
||||
return result.CreateRisk.RiskEdge.Node.ID
|
||||
}
|
||||
|
||||
type VendorBuilder struct {
|
||||
type ThirdPartyBuilder struct {
|
||||
client *testutil.Client
|
||||
attrs Attrs
|
||||
}
|
||||
|
||||
func NewVendor(c *testutil.Client) *VendorBuilder {
|
||||
return &VendorBuilder{client: c, attrs: Attrs{}}
|
||||
func NewThirdParty(c *testutil.Client) *ThirdPartyBuilder {
|
||||
return &ThirdPartyBuilder{client: c, attrs: Attrs{}}
|
||||
}
|
||||
|
||||
func (b *VendorBuilder) WithName(name string) *VendorBuilder {
|
||||
func (b *ThirdPartyBuilder) WithName(name string) *ThirdPartyBuilder {
|
||||
b.attrs["name"] = name
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *VendorBuilder) WithDescription(desc string) *VendorBuilder {
|
||||
func (b *ThirdPartyBuilder) WithDescription(desc string) *ThirdPartyBuilder {
|
||||
b.attrs["description"] = desc
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *VendorBuilder) WithWebsiteUrl(url string) *VendorBuilder {
|
||||
func (b *ThirdPartyBuilder) WithWebsiteUrl(url string) *ThirdPartyBuilder {
|
||||
b.attrs["websiteUrl"] = url
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *VendorBuilder) WithCategory(category string) *VendorBuilder {
|
||||
func (b *ThirdPartyBuilder) WithCategory(category string) *ThirdPartyBuilder {
|
||||
b.attrs["category"] = category
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *VendorBuilder) Create() string {
|
||||
return CreateVendor(b.client, b.attrs)
|
||||
func (b *ThirdPartyBuilder) Create() string {
|
||||
return CreateThirdParty(b.client, b.attrs)
|
||||
}
|
||||
|
||||
type FrameworkBuilder struct {
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestMCP_AuditLog(t *testing.T) {
|
||||
orgID := owner.GetOrganizationID().String()
|
||||
|
||||
// Creating something generates audit log entries
|
||||
factory.CreateVendor(owner)
|
||||
factory.CreateThirdParty(owner)
|
||||
|
||||
var listResult struct {
|
||||
AuditLogEntries []struct {
|
||||
|
||||
142
e2e/mcp/third_party_contact_test.go
Normal file
142
e2e/mcp/third_party_contact_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// 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.
|
||||
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
type thirdPartyContact struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
func TestMCP_AddThirdPartyContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
var result struct {
|
||||
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyContact", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Alice Smith",
|
||||
"email": "alice@example.com",
|
||||
"phone": "+1-555-0100",
|
||||
"role": "Account Manager",
|
||||
}, &result)
|
||||
|
||||
assert.NotEmpty(t, result.ThirdPartyContact.ID)
|
||||
assert.Equal(t, "Alice Smith", result.ThirdPartyContact.Name)
|
||||
}
|
||||
|
||||
func TestMCP_UpdateThirdPartyContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyContact", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Bob Jones",
|
||||
"email": "bob@example.com",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.ThirdPartyContact.ID)
|
||||
|
||||
// Update
|
||||
var updateResult struct {
|
||||
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
|
||||
}
|
||||
mc.CallToolInto("updateThirdPartyContact", map[string]any{
|
||||
"id": addResult.ThirdPartyContact.ID,
|
||||
"name": "Robert Jones",
|
||||
"role": "CTO",
|
||||
}, &updateResult)
|
||||
|
||||
assert.Equal(t, addResult.ThirdPartyContact.ID, updateResult.ThirdPartyContact.ID)
|
||||
assert.Equal(t, "Robert Jones", updateResult.ThirdPartyContact.Name)
|
||||
}
|
||||
|
||||
func TestMCP_DeleteThirdPartyContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyContact", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Contact to delete",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.ThirdPartyContact.ID)
|
||||
|
||||
// Delete
|
||||
var deleteResult struct {
|
||||
DeletedThirdPartyContactID string `json:"deletedThirdPartyContactId"`
|
||||
}
|
||||
mc.CallToolInto("deleteThirdPartyContact", map[string]any{
|
||||
"id": addResult.ThirdPartyContact.ID,
|
||||
}, &deleteResult)
|
||||
|
||||
assert.Equal(t, addResult.ThirdPartyContact.ID, deleteResult.DeletedThirdPartyContactID)
|
||||
}
|
||||
|
||||
func TestMCP_ListThirdPartyContacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
// Create contacts
|
||||
for i := range 3 {
|
||||
var result struct {
|
||||
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyContact", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": factory.SafeName("Contact"),
|
||||
"email": factory.SafeEmail(),
|
||||
}, &result)
|
||||
require.NotEmpty(t, result.ThirdPartyContact.ID)
|
||||
_ = i
|
||||
}
|
||||
|
||||
// List
|
||||
var listResult struct {
|
||||
ThirdPartyContacts []thirdPartyContact `json:"thirdPartyContacts"`
|
||||
}
|
||||
mc.CallToolInto("listThirdPartyContacts", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
}, &listResult)
|
||||
|
||||
assert.GreaterOrEqual(t, len(listResult.ThirdPartyContacts), 3)
|
||||
}
|
||||
135
e2e/mcp/third_party_service_test.go
Normal file
135
e2e/mcp/third_party_service_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// 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.
|
||||
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
type thirdPartyService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func TestMCP_AddThirdPartyService(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
var result struct {
|
||||
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyService", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Cloud Storage",
|
||||
"description": "Object storage service",
|
||||
}, &result)
|
||||
|
||||
assert.NotEmpty(t, result.ThirdPartyService.ID)
|
||||
assert.Equal(t, "Cloud Storage", result.ThirdPartyService.Name)
|
||||
}
|
||||
|
||||
func TestMCP_UpdateThirdPartyService(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyService", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Original Service",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.ThirdPartyService.ID)
|
||||
|
||||
// Update
|
||||
var updateResult struct {
|
||||
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
|
||||
}
|
||||
mc.CallToolInto("updateThirdPartyService", map[string]any{
|
||||
"id": addResult.ThirdPartyService.ID,
|
||||
"name": "Updated Service",
|
||||
}, &updateResult)
|
||||
|
||||
assert.Equal(t, addResult.ThirdPartyService.ID, updateResult.ThirdPartyService.ID)
|
||||
assert.Equal(t, "Updated Service", updateResult.ThirdPartyService.Name)
|
||||
}
|
||||
|
||||
func TestMCP_DeleteThirdPartyService(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyService", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": "Service to delete",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.ThirdPartyService.ID)
|
||||
|
||||
// Delete
|
||||
var deleteResult struct {
|
||||
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
|
||||
}
|
||||
mc.CallToolInto("deleteThirdPartyService", map[string]any{
|
||||
"id": addResult.ThirdPartyService.ID,
|
||||
}, &deleteResult)
|
||||
|
||||
assert.Equal(t, addResult.ThirdPartyService.ID, deleteResult.DeletedThirdPartyServiceID)
|
||||
}
|
||||
|
||||
func TestMCP_ListThirdPartyServices(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
thirdPartyID := factory.CreateThirdParty(owner)
|
||||
|
||||
// Create services
|
||||
for i := range 3 {
|
||||
var result struct {
|
||||
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
|
||||
}
|
||||
mc.CallToolInto("addThirdPartyService", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
"name": factory.SafeName("Service"),
|
||||
}, &result)
|
||||
require.NotEmpty(t, result.ThirdPartyService.ID)
|
||||
_ = i
|
||||
}
|
||||
|
||||
// List
|
||||
var listResult struct {
|
||||
ThirdPartyServices []thirdPartyService `json:"thirdPartyServices"`
|
||||
}
|
||||
mc.CallToolInto("listThirdPartyServices", map[string]any{
|
||||
"thirdPartyId": thirdPartyID,
|
||||
}, &listResult)
|
||||
|
||||
assert.GreaterOrEqual(t, len(listResult.ThirdPartyServices), 3)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMCP_Vendor_CRUD(t *testing.T) {
|
||||
func TestMCP_ThirdParty_CRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
@@ -31,67 +31,67 @@ func TestMCP_Vendor_CRUD(t *testing.T) {
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
Vendor struct {
|
||||
ThirdParty struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"vendor"`
|
||||
} `json:"third_party"`
|
||||
}
|
||||
name := factory.SafeName("Vendor")
|
||||
mc.CallToolInto("addVendor", map[string]any{
|
||||
name := factory.SafeName("ThirdParty")
|
||||
mc.CallToolInto("addThirdParty", map[string]any{
|
||||
"organizationId": orgID,
|
||||
"name": name,
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.Vendor.ID)
|
||||
assert.Equal(t, name, addResult.Vendor.Name)
|
||||
require.NotEmpty(t, addResult.ThirdParty.ID)
|
||||
assert.Equal(t, name, addResult.ThirdParty.Name)
|
||||
|
||||
// Update
|
||||
var updateResult struct {
|
||||
Vendor struct {
|
||||
ThirdParty struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"vendor"`
|
||||
} `json:"third_party"`
|
||||
}
|
||||
mc.CallToolInto("updateVendor", map[string]any{
|
||||
"id": addResult.Vendor.ID,
|
||||
"name": "Updated Vendor",
|
||||
mc.CallToolInto("updateThirdParty", map[string]any{
|
||||
"id": addResult.ThirdParty.ID,
|
||||
"name": "Updated ThirdParty",
|
||||
}, &updateResult)
|
||||
assert.Equal(t, "Updated Vendor", updateResult.Vendor.Name)
|
||||
assert.Equal(t, "Updated ThirdParty", updateResult.ThirdParty.Name)
|
||||
|
||||
// List
|
||||
var listResult struct {
|
||||
Vendors []struct {
|
||||
ThirdParties []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"vendors"`
|
||||
} `json:"third_parties"`
|
||||
}
|
||||
mc.CallToolInto("listVendors", map[string]any{
|
||||
mc.CallToolInto("listThirdParties", map[string]any{
|
||||
"organizationId": orgID,
|
||||
}, &listResult)
|
||||
assert.NotEmpty(t, listResult.Vendors)
|
||||
assert.NotEmpty(t, listResult.ThirdParties)
|
||||
|
||||
// Delete
|
||||
var deleteResult struct {
|
||||
DeletedVendorID string `json:"deletedVendorId"`
|
||||
DeletedThirdPartyID string `json:"deletedThirdPartyId"`
|
||||
}
|
||||
mc.CallToolInto("deleteVendor", map[string]any{
|
||||
"id": addResult.Vendor.ID,
|
||||
mc.CallToolInto("deleteThirdParty", map[string]any{
|
||||
"id": addResult.ThirdParty.ID,
|
||||
}, &deleteResult)
|
||||
assert.Equal(t, addResult.Vendor.ID, deleteResult.DeletedVendorID)
|
||||
assert.Equal(t, addResult.ThirdParty.ID, deleteResult.DeletedThirdPartyID)
|
||||
|
||||
// Update deleted vendor returns sanitized not-found error
|
||||
msg := mc.CallToolExpectToolError("updateVendor", map[string]any{
|
||||
"id": addResult.Vendor.ID,
|
||||
// Update deleted thirdParty returns sanitized not-found error
|
||||
msg := mc.CallToolExpectToolError("updateThirdParty", map[string]any{
|
||||
"id": addResult.ThirdParty.ID,
|
||||
"name": "Should Fail",
|
||||
})
|
||||
assert.Equal(t, "resource not found", msg)
|
||||
}
|
||||
|
||||
func TestMCP_Vendor_ValidationError(t *testing.T) {
|
||||
func TestMCP_ThirdParty_ValidationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
orgID := owner.GetOrganizationID().String()
|
||||
|
||||
msg := mc.CallToolExpectToolError("addVendor", map[string]any{
|
||||
msg := mc.CallToolExpectToolError("addThirdParty", map[string]any{
|
||||
"organizationId": orgID,
|
||||
"name": "",
|
||||
})
|
||||
@@ -100,16 +100,16 @@ func TestMCP_Vendor_ValidationError(t *testing.T) {
|
||||
assert.NotContains(t, msg, "sql:")
|
||||
}
|
||||
|
||||
func TestMCP_Vendor_PermissionDenied(t *testing.T) {
|
||||
func TestMCP_ThirdParty_PermissionDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
orgID := owner.GetOrganizationID().String()
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
viewerMC := testutil.NewMCPClient(t, viewer)
|
||||
|
||||
msg := viewerMC.CallToolExpectToolError("addVendor", map[string]any{
|
||||
msg := viewerMC.CallToolExpectToolError("addThirdParty", map[string]any{
|
||||
"organizationId": orgID,
|
||||
"name": factory.SafeName("Vendor"),
|
||||
"name": factory.SafeName("ThirdParty"),
|
||||
})
|
||||
assert.Contains(t, msg, "permission denied")
|
||||
assert.NotContains(t, msg, "pq:")
|
||||
@@ -1,142 +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.
|
||||
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
type vendorContact struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
func TestMCP_AddVendorContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
var result struct {
|
||||
VendorContact vendorContact `json:"vendorContact"`
|
||||
}
|
||||
mc.CallToolInto("addVendorContact", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Alice Smith",
|
||||
"email": "alice@example.com",
|
||||
"phone": "+1-555-0100",
|
||||
"role": "Account Manager",
|
||||
}, &result)
|
||||
|
||||
assert.NotEmpty(t, result.VendorContact.ID)
|
||||
assert.Equal(t, "Alice Smith", result.VendorContact.Name)
|
||||
}
|
||||
|
||||
func TestMCP_UpdateVendorContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
VendorContact vendorContact `json:"vendorContact"`
|
||||
}
|
||||
mc.CallToolInto("addVendorContact", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Bob Jones",
|
||||
"email": "bob@example.com",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.VendorContact.ID)
|
||||
|
||||
// Update
|
||||
var updateResult struct {
|
||||
VendorContact vendorContact `json:"vendorContact"`
|
||||
}
|
||||
mc.CallToolInto("updateVendorContact", map[string]any{
|
||||
"id": addResult.VendorContact.ID,
|
||||
"name": "Robert Jones",
|
||||
"role": "CTO",
|
||||
}, &updateResult)
|
||||
|
||||
assert.Equal(t, addResult.VendorContact.ID, updateResult.VendorContact.ID)
|
||||
assert.Equal(t, "Robert Jones", updateResult.VendorContact.Name)
|
||||
}
|
||||
|
||||
func TestMCP_DeleteVendorContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
VendorContact vendorContact `json:"vendorContact"`
|
||||
}
|
||||
mc.CallToolInto("addVendorContact", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Contact to delete",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.VendorContact.ID)
|
||||
|
||||
// Delete
|
||||
var deleteResult struct {
|
||||
DeletedVendorContactID string `json:"deletedVendorContactId"`
|
||||
}
|
||||
mc.CallToolInto("deleteVendorContact", map[string]any{
|
||||
"id": addResult.VendorContact.ID,
|
||||
}, &deleteResult)
|
||||
|
||||
assert.Equal(t, addResult.VendorContact.ID, deleteResult.DeletedVendorContactID)
|
||||
}
|
||||
|
||||
func TestMCP_ListVendorContacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
// Create contacts
|
||||
for i := range 3 {
|
||||
var result struct {
|
||||
VendorContact vendorContact `json:"vendorContact"`
|
||||
}
|
||||
mc.CallToolInto("addVendorContact", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": factory.SafeName("Contact"),
|
||||
"email": factory.SafeEmail(),
|
||||
}, &result)
|
||||
require.NotEmpty(t, result.VendorContact.ID)
|
||||
_ = i
|
||||
}
|
||||
|
||||
// List
|
||||
var listResult struct {
|
||||
VendorContacts []vendorContact `json:"vendorContacts"`
|
||||
}
|
||||
mc.CallToolInto("listVendorContacts", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
}, &listResult)
|
||||
|
||||
assert.GreaterOrEqual(t, len(listResult.VendorContacts), 3)
|
||||
}
|
||||
@@ -1,135 +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.
|
||||
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/e2e/internal/factory"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
type vendorService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func TestMCP_AddVendorService(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
var result struct {
|
||||
VendorService vendorService `json:"vendorService"`
|
||||
}
|
||||
mc.CallToolInto("addVendorService", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Cloud Storage",
|
||||
"description": "Object storage service",
|
||||
}, &result)
|
||||
|
||||
assert.NotEmpty(t, result.VendorService.ID)
|
||||
assert.Equal(t, "Cloud Storage", result.VendorService.Name)
|
||||
}
|
||||
|
||||
func TestMCP_UpdateVendorService(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
VendorService vendorService `json:"vendorService"`
|
||||
}
|
||||
mc.CallToolInto("addVendorService", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Original Service",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.VendorService.ID)
|
||||
|
||||
// Update
|
||||
var updateResult struct {
|
||||
VendorService vendorService `json:"vendorService"`
|
||||
}
|
||||
mc.CallToolInto("updateVendorService", map[string]any{
|
||||
"id": addResult.VendorService.ID,
|
||||
"name": "Updated Service",
|
||||
}, &updateResult)
|
||||
|
||||
assert.Equal(t, addResult.VendorService.ID, updateResult.VendorService.ID)
|
||||
assert.Equal(t, "Updated Service", updateResult.VendorService.Name)
|
||||
}
|
||||
|
||||
func TestMCP_DeleteVendorService(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
// Create
|
||||
var addResult struct {
|
||||
VendorService vendorService `json:"vendorService"`
|
||||
}
|
||||
mc.CallToolInto("addVendorService", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": "Service to delete",
|
||||
}, &addResult)
|
||||
require.NotEmpty(t, addResult.VendorService.ID)
|
||||
|
||||
// Delete
|
||||
var deleteResult struct {
|
||||
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
|
||||
}
|
||||
mc.CallToolInto("deleteVendorService", map[string]any{
|
||||
"id": addResult.VendorService.ID,
|
||||
}, &deleteResult)
|
||||
|
||||
assert.Equal(t, addResult.VendorService.ID, deleteResult.DeletedVendorServiceID)
|
||||
}
|
||||
|
||||
func TestMCP_ListVendorServices(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
mc := testutil.NewMCPClient(t, owner)
|
||||
vendorID := factory.CreateVendor(owner)
|
||||
|
||||
// Create services
|
||||
for i := range 3 {
|
||||
var result struct {
|
||||
VendorService vendorService `json:"vendorService"`
|
||||
}
|
||||
mc.CallToolInto("addVendorService", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
"name": factory.SafeName("Service"),
|
||||
}, &result)
|
||||
require.NotEmpty(t, result.VendorService.ID)
|
||||
_ = i
|
||||
}
|
||||
|
||||
// List
|
||||
var listResult struct {
|
||||
VendorServices []vendorService `json:"vendorServices"`
|
||||
}
|
||||
mc.CallToolInto("listVendorServices", map[string]any{
|
||||
"vendorId": vendorID,
|
||||
}, &listResult)
|
||||
|
||||
assert.GreaterOrEqual(t, len(listResult.VendorServices), 3)
|
||||
}
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -35,8 +35,8 @@
|
||||
"@probo/react-lazy": "1.0.0",
|
||||
"@probo/relay": "^1.0.0",
|
||||
"@probo/routes": "^1.0.0",
|
||||
"@probo/third-parties": "0.0.1",
|
||||
"@probo/ui": "1.0.0",
|
||||
"@probo/vendors": "0.0.1",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.1.0",
|
||||
@@ -3246,6 +3246,10 @@
|
||||
"resolved": "packages/routes",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@probo/third-parties": {
|
||||
"resolved": "packages/third-parties",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@probo/trust": {
|
||||
"resolved": "apps/trust",
|
||||
"link": true
|
||||
@@ -3258,10 +3262,6 @@
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@probo/vendors": {
|
||||
"resolved": "packages/vendors",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -19633,6 +19633,11 @@
|
||||
"relay-runtime": "^20.1.1"
|
||||
}
|
||||
},
|
||||
"packages/third-parties": {
|
||||
"name": "@probo/third-parties",
|
||||
"version": "0.0.1",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
"packages/tsconfig": {
|
||||
"name": "@probo/tsconfig",
|
||||
"version": "0.0.1",
|
||||
@@ -20010,11 +20015,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/vendors": {
|
||||
"name": "@probo/vendors",
|
||||
"version": "0.0.1",
|
||||
"license": "CC BY-SA 4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,11 @@ export class Probo implements INodeType {
|
||||
value: 'task',
|
||||
description: 'Manage tasks',
|
||||
},
|
||||
{
|
||||
name: 'Third Party',
|
||||
value: 'thirdParty',
|
||||
description: 'Manage third parties',
|
||||
},
|
||||
{
|
||||
name: 'TIA',
|
||||
value: 'tia',
|
||||
@@ -216,11 +221,6 @@ export class Probo implements INodeType {
|
||||
value: 'user',
|
||||
description: 'Manage organization users (profiles)',
|
||||
},
|
||||
{
|
||||
name: 'Vendor',
|
||||
value: 'vendor',
|
||||
description: 'Manage vendors',
|
||||
},
|
||||
{
|
||||
name: 'Webhook',
|
||||
value: 'webhook',
|
||||
|
||||
@@ -111,8 +111,8 @@ export const description: INodeProperties[] = [
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Vendor IDs',
|
||||
name: 'vendorIds',
|
||||
displayName: 'ThirdParty IDs',
|
||||
name: 'thirdPartyIds',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
@@ -121,7 +121,7 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Comma-separated list of vendor IDs',
|
||||
description: 'Comma-separated list of thirdParty IDs',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
@@ -144,11 +144,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -164,10 +164,10 @@ export async function execute(
|
||||
const ownerId = this.getNodeParameter('ownerId', itemIndex) as string;
|
||||
const assetType = this.getNodeParameter('assetType', itemIndex) as string;
|
||||
const dataTypesStored = this.getNodeParameter('dataTypesStored', itemIndex) as string;
|
||||
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
|
||||
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -178,8 +178,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -200,7 +200,7 @@ export async function execute(
|
||||
assetType
|
||||
dataTypesStored
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export async function execute(
|
||||
}
|
||||
`;
|
||||
|
||||
const vendorIds = vendorIdsStr ? vendorIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
|
||||
const thirdPartyIds = thirdPartyIdsStr ? thirdPartyIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
@@ -219,7 +219,7 @@ export async function execute(
|
||||
ownerId,
|
||||
assetType,
|
||||
dataTypesStored,
|
||||
...(vendorIds && vendorIds.length > 0 && { vendorIds }),
|
||||
...(thirdPartyIds && thirdPartyIds.length > 0 && { thirdPartyIds }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -68,7 +68,7 @@ export async function execute(
|
||||
const assetId = this.getNodeParameter('assetId', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -79,8 +79,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -100,7 +100,7 @@ export async function execute(
|
||||
assetType
|
||||
dataTypesStored
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -100,7 +100,7 @@ export async function execute(
|
||||
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -111,8 +111,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -135,7 +135,7 @@ export async function execute(
|
||||
assetType
|
||||
dataTypesStored
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export const description: INodeProperties[] = [
|
||||
description: 'The types of data stored in the asset',
|
||||
},
|
||||
{
|
||||
displayName: 'Vendor IDs',
|
||||
name: 'vendorIds',
|
||||
displayName: 'ThirdParty IDs',
|
||||
name: 'thirdPartyIds',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
@@ -116,7 +116,7 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Comma-separated list of vendor IDs',
|
||||
description: 'Comma-separated list of thirdParty IDs',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
@@ -139,11 +139,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -159,10 +159,10 @@ export async function execute(
|
||||
const ownerId = this.getNodeParameter('ownerId', itemIndex, '') as string;
|
||||
const assetType = this.getNodeParameter('assetType', itemIndex, '') as string;
|
||||
const dataTypesStored = this.getNodeParameter('dataTypesStored', itemIndex, '') as string;
|
||||
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
|
||||
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -173,8 +173,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -194,7 +194,7 @@ export async function execute(
|
||||
assetType
|
||||
dataTypesStored
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -208,9 +208,9 @@ export async function execute(
|
||||
if (ownerId) input.ownerId = ownerId;
|
||||
if (assetType) input.assetType = assetType;
|
||||
if (dataTypesStored) input.dataTypesStored = dataTypesStored;
|
||||
if (vendorIdsStr) {
|
||||
const vendorIds = vendorIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
|
||||
if (vendorIds.length > 0) input.vendorIds = vendorIds;
|
||||
if (thirdPartyIdsStr) {
|
||||
const thirdPartyIds = thirdPartyIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
|
||||
if (thirdPartyIds.length > 0) input.thirdPartyIds = thirdPartyIds;
|
||||
}
|
||||
|
||||
const responseData = await proboApiRequest.call(this, query, { input });
|
||||
|
||||
@@ -91,8 +91,8 @@ export const description: INodeProperties[] = [
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Vendor IDs',
|
||||
name: 'vendorIds',
|
||||
displayName: 'ThirdParty IDs',
|
||||
name: 'thirdPartyIds',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
@@ -101,7 +101,7 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Comma-separated list of vendor IDs',
|
||||
description: 'Comma-separated list of thirdParty IDs',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
@@ -124,11 +124,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -142,10 +142,10 @@ export async function execute(
|
||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||
const dataClassification = this.getNodeParameter('dataClassification', itemIndex) as string;
|
||||
const ownerId = this.getNodeParameter('ownerId', itemIndex) as string;
|
||||
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
|
||||
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -156,8 +156,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -176,7 +176,7 @@ export async function execute(
|
||||
name
|
||||
dataClassification
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export async function execute(
|
||||
}
|
||||
`;
|
||||
|
||||
const vendorIds = vendorIdsStr ? vendorIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
|
||||
const thirdPartyIds = thirdPartyIdsStr ? thirdPartyIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
@@ -193,7 +193,7 @@ export async function execute(
|
||||
name,
|
||||
dataClassification,
|
||||
ownerId,
|
||||
...(vendorIds && vendorIds.length > 0 && { vendorIds }),
|
||||
...(thirdPartyIds && thirdPartyIds.length > 0 && { thirdPartyIds }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -68,7 +68,7 @@ export async function execute(
|
||||
const datumId = this.getNodeParameter('datumId', itemIndex) as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -79,8 +79,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -98,7 +98,7 @@ export async function execute(
|
||||
name
|
||||
dataClassification
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -100,7 +100,7 @@ export async function execute(
|
||||
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -111,8 +111,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -133,7 +133,7 @@ export async function execute(
|
||||
name
|
||||
dataClassification
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ export const description: INodeProperties[] = [
|
||||
description: 'The ID of the owner (People)',
|
||||
},
|
||||
{
|
||||
displayName: 'Vendor IDs',
|
||||
name: 'vendorIds',
|
||||
displayName: 'ThirdParty IDs',
|
||||
name: 'thirdPartyIds',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
@@ -98,7 +98,7 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'Comma-separated list of vendor IDs',
|
||||
description: 'Comma-separated list of thirdParty IDs',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
@@ -121,11 +121,11 @@ export const description: INodeProperties[] = [
|
||||
description: 'Whether to include owner details in the response',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Vendors',
|
||||
name: 'includeVendors',
|
||||
displayName: 'Include ThirdParties',
|
||||
name: 'includeThirdParties',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include vendors in the response',
|
||||
description: 'Whether to include thirdParties in the response',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -139,10 +139,10 @@ export async function execute(
|
||||
const name = this.getNodeParameter('name', itemIndex, '') as string;
|
||||
const dataClassification = this.getNodeParameter('dataClassification', itemIndex, '') as string;
|
||||
const ownerId = this.getNodeParameter('ownerId', itemIndex, '') as string;
|
||||
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
|
||||
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
includeOwner?: boolean;
|
||||
includeVendors?: boolean;
|
||||
includeThirdParties?: boolean;
|
||||
};
|
||||
|
||||
const ownerFragment = options.includeOwner
|
||||
@@ -153,8 +153,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const vendorsFragment = options.includeVendors
|
||||
? `vendors(first: 100) {
|
||||
const thirdPartiesFragment = options.includeThirdParties
|
||||
? `thirdParties(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -172,7 +172,7 @@ export async function execute(
|
||||
name
|
||||
dataClassification
|
||||
${ownerFragment}
|
||||
${vendorsFragment}
|
||||
${thirdPartiesFragment}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -184,9 +184,9 @@ export async function execute(
|
||||
if (name) input.name = name;
|
||||
if (dataClassification) input.dataClassification = dataClassification;
|
||||
if (ownerId) input.ownerId = ownerId;
|
||||
if (vendorIdsStr) {
|
||||
const vendorIds = vendorIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
|
||||
if (vendorIds.length > 0) input.vendorIds = vendorIds;
|
||||
if (thirdPartyIdsStr) {
|
||||
const thirdPartyIds = thirdPartyIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
|
||||
if (thirdPartyIds.length > 0) input.thirdPartyIds = thirdPartyIds;
|
||||
}
|
||||
|
||||
const responseData = await proboApiRequest.call(this, query, { input });
|
||||
|
||||
@@ -41,7 +41,7 @@ import * as statementOfApplicability from './statementOfApplicability';
|
||||
import * as task from './task';
|
||||
import * as tia from './tia';
|
||||
import * as trustCenter from './trustCenter';
|
||||
import * as vendor from './vendor';
|
||||
import * as thirdParty from './thirdParty';
|
||||
import * as webhook from './webhook';
|
||||
|
||||
export interface ResourceModule {
|
||||
@@ -83,7 +83,7 @@ export const resources: Record<string, ResourceModule> = {
|
||||
task: task as ResourceModule,
|
||||
tia: tia as ResourceModule,
|
||||
trustCenter: trustCenter as ResourceModule,
|
||||
vendor: vendor as ResourceModule,
|
||||
thirdParty: thirdParty as ResourceModule,
|
||||
webhook: webhook as ResourceModule,
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
@@ -36,12 +36,12 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The name of the vendor',
|
||||
description: 'The name of the thirdParty',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -53,12 +53,12 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The description of the vendor',
|
||||
description: 'The description of the thirdParty',
|
||||
},
|
||||
{
|
||||
displayName: 'Category',
|
||||
@@ -66,12 +66,12 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The category of the vendor',
|
||||
description: 'The category of the thirdParty',
|
||||
},
|
||||
{
|
||||
displayName: 'Website URL',
|
||||
@@ -79,12 +79,12 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The website URL of the vendor',
|
||||
description: 'The website URL of the thirdParty',
|
||||
},
|
||||
{
|
||||
displayName: 'Legal Name',
|
||||
@@ -92,12 +92,12 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The legal name of the vendor',
|
||||
description: 'The legal name of the thirdParty',
|
||||
},
|
||||
{
|
||||
displayName: 'Headquarter Address',
|
||||
@@ -105,12 +105,12 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The headquarter address of the vendor',
|
||||
description: 'The headquarter address of the thirdParty',
|
||||
},
|
||||
{
|
||||
displayName: 'Business Owner ID',
|
||||
@@ -118,7 +118,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
@@ -131,7 +131,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
@@ -146,7 +146,7 @@ export const description: INodeProperties[] = [
|
||||
default: {},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['create'],
|
||||
},
|
||||
},
|
||||
@@ -200,7 +200,7 @@ export const description: INodeProperties[] = [
|
||||
name: 'statusPageUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'The status page URL of the vendor',
|
||||
description: 'The status page URL of the thirdParty',
|
||||
},
|
||||
{
|
||||
displayName: 'Subprocessors List URL',
|
||||
@@ -252,9 +252,9 @@ export async function execute(
|
||||
};
|
||||
|
||||
const query = `
|
||||
mutation CreateVendor($input: CreateVendorInput!) {
|
||||
createVendor(input: $input) {
|
||||
vendorEdge {
|
||||
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
name
|
||||
@@ -17,17 +17,17 @@ import { proboApiRequest } from '../../GenericFunctions';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Vendor ID',
|
||||
name: 'vendorId',
|
||||
displayName: 'ThirdParty ID',
|
||||
name: 'thirdPartyId',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createContact'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The ID of the vendor',
|
||||
description: 'The ID of the thirdParty',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createContact'],
|
||||
},
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export const description: INodeProperties[] = [
|
||||
placeholder: 'name@email.com',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createContact'],
|
||||
},
|
||||
},
|
||||
@@ -63,7 +63,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createContact'],
|
||||
},
|
||||
},
|
||||
@@ -76,7 +76,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createContact'],
|
||||
},
|
||||
},
|
||||
@@ -89,16 +89,16 @@ export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const vendorId = this.getNodeParameter('vendorId', itemIndex) as string;
|
||||
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
|
||||
const fullName = this.getNodeParameter('fullName', itemIndex, '') as string;
|
||||
const email = this.getNodeParameter('email', itemIndex, '') as string;
|
||||
const phone = this.getNodeParameter('phone', itemIndex, '') as string;
|
||||
const role = this.getNodeParameter('role', itemIndex, '') as string;
|
||||
|
||||
const query = `
|
||||
mutation CreateVendorContact($input: CreateVendorContactInput!) {
|
||||
createVendorContact(input: $input) {
|
||||
vendorContactEdge {
|
||||
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
|
||||
createThirdPartyContact(input: $input) {
|
||||
thirdPartyContactEdge {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
@@ -113,7 +113,7 @@ export async function execute(
|
||||
}
|
||||
`;
|
||||
|
||||
const input: Record<string, unknown> = { vendorId };
|
||||
const input: Record<string, unknown> = { thirdPartyId };
|
||||
if (fullName) input.fullName = fullName;
|
||||
if (email) input.email = email;
|
||||
if (phone) input.phone = phone;
|
||||
@@ -17,17 +17,17 @@ import { proboApiRequest } from '../../GenericFunctions';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Vendor ID',
|
||||
name: 'vendorId',
|
||||
displayName: 'ThirdParty ID',
|
||||
name: 'thirdPartyId',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createRiskAssessment'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The ID of the vendor',
|
||||
description: 'The ID of the thirdParty',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'dateTime',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createRiskAssessment'],
|
||||
},
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createRiskAssessment'],
|
||||
},
|
||||
},
|
||||
@@ -71,7 +71,7 @@ export const description: INodeProperties[] = [
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createRiskAssessment'],
|
||||
},
|
||||
},
|
||||
@@ -94,7 +94,7 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createRiskAssessment'],
|
||||
},
|
||||
},
|
||||
@@ -107,7 +107,7 @@ export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const vendorId = this.getNodeParameter('vendorId', itemIndex) as string;
|
||||
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
|
||||
const expiresAtRaw = this.getNodeParameter('expiresAt', itemIndex) as string;
|
||||
const dataSensitivity = this.getNodeParameter('dataSensitivity', itemIndex) as string;
|
||||
const businessImpact = this.getNodeParameter('businessImpact', itemIndex) as string;
|
||||
@@ -117,9 +117,9 @@ export async function execute(
|
||||
const expiresAt = new Date(expiresAtRaw).toISOString();
|
||||
|
||||
const query = `
|
||||
mutation CreateVendorRiskAssessment($input: CreateVendorRiskAssessmentInput!) {
|
||||
createVendorRiskAssessment(input: $input) {
|
||||
vendorRiskAssessmentEdge {
|
||||
mutation CreateThirdPartyRiskAssessment($input: CreateThirdPartyRiskAssessmentInput!) {
|
||||
createThirdPartyRiskAssessment(input: $input) {
|
||||
thirdPartyRiskAssessmentEdge {
|
||||
node {
|
||||
id
|
||||
expiresAt
|
||||
@@ -135,7 +135,7 @@ export async function execute(
|
||||
`;
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
vendorId,
|
||||
thirdPartyId,
|
||||
expiresAt,
|
||||
dataSensitivity,
|
||||
businessImpact,
|
||||
@@ -17,17 +17,17 @@ import { proboApiRequest } from '../../GenericFunctions';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Vendor ID',
|
||||
name: 'vendorId',
|
||||
displayName: 'ThirdParty ID',
|
||||
name: 'thirdPartyId',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createService'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The ID of the vendor',
|
||||
description: 'The ID of the thirdParty',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -36,12 +36,12 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createService'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The name of the vendor service',
|
||||
description: 'The name of the thirdParty service',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -53,12 +53,12 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['vendor'],
|
||||
resource: ['thirdParty'],
|
||||
operation: ['createService'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
description: 'The description of the vendor service',
|
||||
description: 'The description of the thirdParty service',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -66,14 +66,14 @@ export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<INodeExecutionData> {
|
||||
const vendorId = this.getNodeParameter('vendorId', itemIndex) as string;
|
||||
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
|
||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||
const description = this.getNodeParameter('description', itemIndex, '') as string;
|
||||
|
||||
const query = `
|
||||
mutation CreateVendorService($input: CreateVendorServiceInput!) {
|
||||
createVendorService(input: $input) {
|
||||
vendorServiceEdge {
|
||||
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
|
||||
createThirdPartyService(input: $input) {
|
||||
thirdPartyServiceEdge {
|
||||
node {
|
||||
id
|
||||
name
|
||||
@@ -87,7 +87,7 @@ export async function execute(
|
||||
`;
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
vendorId,
|
||||
thirdPartyId,
|
||||
name,
|
||||
};
|
||||
if (description) input.description = description;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user