Fix vendor pages permissions handling

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2026-01-02 13:52:56 +01:00
committed by Bryan Frimin
parent aa6ca266d0
commit 44ce8fdbe6
34 changed files with 1205 additions and 443 deletions

View File

@@ -29,21 +29,17 @@ import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { complianceReportsFragment } from "./tabs/VendorComplianceTab";
import type { VendorComplianceTabFragment$key } from "/__generated__/core/VendorComplianceTabFragment.graphql";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
type Props = {
queryRef: PreloadedQuery<VendorGraphNodeQuery>;
};
export default function VendorDetailPage(props: Props) {
const data = usePreloadedQuery(vendorNodeQuery, props.queryRef);
const vendor = data.node;
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { isAuthorized } = use(PermissionsContext);
validateSnapshotConsistency(vendor, snapshotId);
const deleteVendor = useDeleteVendor(
@@ -93,14 +89,14 @@ export default function VendorDetailPage(props: Props) {
</div>
{!isSnapshotMode && (
<div className="flex gap-2 items-center">
{isAuthorized("Vendor", "assessVendor") && (
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id!}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{isAuthorized("Vendor", "deleteVendor") && (
{vendor.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"

View File

@@ -37,8 +37,6 @@ import type {
} from "/__generated__/core/VendorGraphPaginatedFragment.graphql";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
@@ -51,7 +49,6 @@ export default function VendorsPage(props: Props) {
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { isAuthorized } = use(PermissionsContext);
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
const pagination = usePaginationFragment(
@@ -66,8 +63,7 @@ export default function VendorsPage(props: Props) {
const hasAnyAction =
!isSnapshotMode &&
(isAuthorized("Vendor", "updateVendor") ||
isAuthorized("Vendor", "deleteVendor"));
vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
return (
<div className="space-y-6">
@@ -78,7 +74,7 @@ export default function VendorsPage(props: Props) {
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
)}
>
{!isSnapshotMode && isAuthorized("Organization", "createVendor") && (
{!isSnapshotMode && data.node.canCreateVendor && (
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
@@ -128,7 +124,6 @@ function VendorRow({
const isSnapshotMode = Boolean(snapshotId);
const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const { isAuthorized } = use(PermissionsContext);
const deleteVendor = useDeleteVendor(vendor, connectionId);
const vendorUrl =
@@ -159,7 +154,7 @@ function VendorRow({
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{isAuthorized("Vendor", "deleteVendor") && (
{vendor.canDelete && (
<DropdownItem
onClick={deleteVendor}
variant="danger"

View File

@@ -29,6 +29,8 @@ const createContactMutation = graphql`
createVendorContact(input: $input) {
vendorContactEdge @prependEdge(connections: $connections) {
node {
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
...VendorContactsTabFragment_contact
}
}
@@ -47,8 +49,21 @@ export function CreateContactDialog({
const schema = z.object({
fullName: z.string().optional(),
email: z.union([z.string().email(__("Please enter a valid email address")), z.literal("")]),
phone: z.union([z.string().regex(phoneRegex, __("Phone number must be in international format (e.g., +1234567890)")), z.literal("")]),
email: z.union([
z.string().email(__("Please enter a valid email address")),
z.literal(""),
]),
phone: z.union([
z
.string()
.regex(
phoneRegex,
__(
"Phone number must be in international format (e.g., +1234567890)",
),
),
z.literal(""),
]),
role: z.string().optional(),
});
@@ -61,14 +76,14 @@ export function CreateContactDialog({
phone: "",
role: "",
},
}
},
);
const [createContact, isLoading] = useMutationWithToasts(
createContactMutation,
{
successMessage: __("Contact created successfully."),
errorMessage: __("Failed to create contact"),
}
},
);
const onSubmit = handleSubmit((data) => {
@@ -96,9 +111,7 @@ export function CreateContactDialog({
className="max-w-lg"
ref={dialogRef}
trigger={children}
title={
<Breadcrumb items={[__("Contacts"), __("New Contact")]} />
}
title={<Breadcrumb items={[__("Contacts"), __("New Contact")]} />}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">

View File

@@ -14,15 +14,11 @@ import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import type { VendorContactsTabFragment_contact$data } from "/__generated__/core/VendorContactsTabFragment_contact.graphql";
type Props = {
contactId: string;
contact: {
fullName?: string | null;
email?: string | null;
phone?: string | null;
role?: string | null;
};
contact: VendorContactsTabFragment_contact$data;
onClose: () => void;
};
@@ -43,29 +39,39 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
const schema = z.object({
fullName: z.string().optional(),
email: z.union([z.string().email(__("Please enter a valid email address")), z.literal("")]),
phone: z.union([z.string().regex(phoneRegex, __("Phone number must be in international format (e.g., +1234567890)")), z.literal("")]),
email: z.union([
z.string().email(__("Please enter a valid email address")),
z.literal(""),
]),
phone: z.union([
z
.string()
.regex(
phoneRegex,
__(
"Phone number must be in international format (e.g., +1234567890)",
),
),
z.literal(""),
]),
role: z.string().optional(),
});
const { register, handleSubmit, formState } = useFormWithSchema(
schema,
{
defaultValues: {
fullName: contact.fullName || "",
email: contact.email || "",
phone: contact.phone || "",
role: contact.role || "",
},
}
);
const { register, handleSubmit, formState } = useFormWithSchema(schema, {
defaultValues: {
fullName: contact.fullName || "",
email: contact.email || "",
phone: contact.phone || "",
role: contact.role || "",
},
});
const [updateContact, isLoading] = useMutationWithToasts(
updateContactMutation,
{
successMessage: __("Contact updated successfully."),
errorMessage: __("Failed to update contact"),
}
},
);
const onSubmit = handleSubmit((data) => {
@@ -95,9 +101,7 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
className="max-w-lg"
ref={dialogRef}
onClose={onClose}
title={
<Breadcrumb items={[__("Contacts"), __("Edit Contact")]} />
}
title={<Breadcrumb items={[__("Contacts"), __("Edit Contact")]} />}
>
<form onSubmit={onSubmit}>
<DialogContent padded className="space-y-4">

View File

@@ -10,35 +10,32 @@ import {
} from "@probo/ui";
import { Controller } from "react-hook-form";
import { useVendorForm } from "/hooks/forms/useVendorForm";
import type { useVendorFormFragment$key } from "/__generated__/core/useVendorFormFragment.graphql";
import { useOutletContext, useParams } from "react-router";
import {
certificationCategoryLabel,
certifications,
objectEntries,
} from "@probo/helpers";
import { use, useRef, useState } from "react";
import { useRef, useState } from "react";
import clsx from "clsx";
import { PermissionsContext } from "/providers/PermissionsContext";
import type { VendorGraphNodeQuery$data } from "/__generated__/core/VendorGraphNodeQuery.graphql";
/**
* Vendor certifications tab
*/
export default function VendorCertificationsTab() {
const { vendor } = useOutletContext<{
vendor: useVendorFormFragment$key & { name: string };
vendor: VendorGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { control, handleSubmit } = useVendorForm(vendor);
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { isAuthorized } = use(PermissionsContext);
const canUpdateVendor = isAuthorized("Vendor", "updateVendor");
return (
<form
className="space-y-4"
onSubmit={!isSnapshotMode && canUpdateVendor ? handleSubmit : undefined}
onSubmit={!isSnapshotMode && vendor.canUpdate ? handleSubmit : undefined}
>
<Card padded>
<Controller
@@ -48,12 +45,12 @@ export default function VendorCertificationsTab() {
<Certifications
onValueChange={field.onChange}
value={field.value ?? []}
readOnly={isSnapshotMode || !canUpdateVendor}
readOnly={isSnapshotMode || !vendor.canUpdate}
/>
)}
/>
</Card>
{!isSnapshotMode && canUpdateVendor && (
{!isSnapshotMode && vendor.canUpdate && (
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
</div>

View File

@@ -20,8 +20,9 @@ import type { VendorComplianceTabFragment_report$key } from "/__generated__/core
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { sprintf, fileSize, formatDate } from "@probo/helpers";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
import type { VendorGraphNodeQuery$data } from "/__generated__/core/VendorGraphNodeQuery.graphql";
import type { ComplianceReportListQuery } from "/__generated__/core/ComplianceReportListQuery.graphql";
import type { ComponentProps } from "react";
export const complianceReportsFragment = graphql`
fragment VendorComplianceTabFragment on Vendor
@@ -44,6 +45,7 @@ export const complianceReportsFragment = graphql`
edges {
node {
id
canDelete: permission(action: "core:vendor-compliance-report:delete")
...VendorComplianceTabFragment_report
}
}
@@ -62,6 +64,7 @@ const complianceReportFragment = graphql`
mimeType
size
}
canDelete: permission(action: "core:vendor-compliance-report:delete")
}
`;
@@ -94,26 +97,18 @@ const deleteReportMutation = graphql`
export default function VendorComplianceTab() {
const { vendor } = useOutletContext<{
vendor: VendorComplianceTabFragment$key & { name: string; id: string };
vendor: VendorGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment(
complianceReportsFragment,
vendor,
);
const [data, refetch] = useRefetchableFragment<
ComplianceReportListQuery,
VendorComplianceTabFragment$key
>(complianceReportsFragment, vendor);
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map((edge) => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { isAuthorized } = use(PermissionsContext);
const canUploadReport = isAuthorized(
"Vendor",
"uploadVendorComplianceReport",
);
const canDeleteReport = isAuthorized(
"VendorComplianceReport",
"deleteVendorComplianceReport",
);
const canDeleteSomeReport = reports.some(({ canDelete }) => canDelete);
usePageTitle(vendor.name + " - " + __("Compliance reports"));
@@ -141,7 +136,7 @@ export default function VendorComplianceTab() {
return (
<div className="space-y-6">
{!isSnapshotMode && canUploadReport && (
{!isSnapshotMode && vendor.canUploadComplianceReport && (
<Dropzone
description={__("Only PDF files up to 10MB are allowed")}
isUploading={isMutating}
@@ -152,14 +147,16 @@ export default function VendorComplianceTab() {
maxSize={10}
/>
)}
<SortableTable refetch={refetch}>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<Th>{__("Report name")}</Th>
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
<Th>{__("Valid until")}</Th>
<Th>{__("File size")}</Th>
{!isSnapshotMode && canDeleteReport && <Th>{__("Actions")}</Th>}
{!isSnapshotMode && canDeleteSomeReport && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -169,7 +166,6 @@ export default function VendorComplianceTab() {
reportKey={report}
connectionId={connectionId}
isSnapshotMode={isSnapshotMode}
canDelete={canDeleteReport}
/>
))}
</Tbody>
@@ -182,7 +178,6 @@ type ReportRowProps = {
reportKey: VendorComplianceTabFragment_report$key;
connectionId: string;
isSnapshotMode: boolean;
canDelete?: boolean;
};
function ReportRow(props: ReportRowProps) {
@@ -225,7 +220,7 @@ function ReportRow(props: ReportRowProps) {
<Td>{formatDate(report.reportDate)}</Td>
<Td>{formatDate(report.validUntil)}</Td>
<Td>{fileSize(__, report.file?.size ?? 0)}</Td>
{!props.isSnapshotMode && props.canDelete && (
{!props.isSnapshotMode && report.canDelete && (
<Td width={50} className="text-end">
<ActionDropdown>
<DropdownItem

View File

@@ -19,14 +19,18 @@ import {
useConfirm,
} from "@probo/ui";
import { useFragment, useRefetchableFragment } from "react-relay";
import type { VendorContactsTabFragment_contact$key } from "/__generated__/core/VendorContactsTabFragment_contact.graphql";
import type {
VendorContactsTabFragment_contact$data,
VendorContactsTabFragment_contact$key,
} from "/__generated__/core/VendorContactsTabFragment_contact.graphql";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { sprintf } from "@probo/helpers";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { CreateContactDialog } from "../dialogs/CreateContactDialog";
import { EditContactDialog } from "../dialogs/EditContactDialog";
import { use, useState } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
import { useState, type ComponentProps } from "react";
import type { VendorGraphNodeQuery$data } from "/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorContactsListQuery } from "/__generated__/core/VendorContactsListQuery.graphql";
export const vendorContactsFragment = graphql`
fragment VendorContactsTabFragment on Vendor
@@ -49,6 +53,8 @@ export const vendorContactsFragment = graphql`
edges {
node {
id
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
...VendorContactsTabFragment_contact
}
}
@@ -65,6 +71,8 @@ const contactFragment = graphql`
role
createdAt
updatedAt
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
}
`;
@@ -81,29 +89,22 @@ const deleteContactMutation = graphql`
export default function VendorContactsTab() {
const { vendor } = useOutletContext<{
vendor: VendorContactsTabFragment$key & { name: string; id: string };
vendor: VendorGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment(
vendorContactsFragment,
vendor,
);
const [data, refetch] = useRefetchableFragment<
VendorContactsListQuery,
VendorContactsTabFragment$key
>(vendorContactsFragment, vendor);
const connectionId = data.contacts.__id;
const contacts = data.contacts.edges.map((edge) => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [editingContact, setEditingContact] = useState<{
id: string;
fullName?: string | null;
email?: string | null;
phone?: string | null;
role?: string | null;
} | null>(null);
const { isAuthorized } = use(PermissionsContext);
const canCreateContact = isAuthorized("Vendor", "createVendorContact");
const canUpdateContact = isAuthorized("VendorContact", "updateVendorContact");
const canDeleteContact = isAuthorized("VendorContact", "deleteVendorContact");
const hasAnyAction = canUpdateContact || canDeleteContact;
const [editingContact, setEditingContact] =
useState<VendorContactsTabFragment_contact$data | null>(null);
const hasAnyAction = contacts.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(vendor.name + " - " + __("Contacts"));
@@ -113,14 +114,16 @@ export default function VendorContactsTab() {
title={__("Contacts")}
description={__("Manage vendor contacts and their information.")}
>
{!isSnapshotMode && canCreateContact && (
{!isSnapshotMode && vendor.canCreateContact && (
<CreateContactDialog vendorId={vendor.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
)}
</PageHeader>
<SortableTable refetch={refetch}>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
@@ -138,14 +141,12 @@ export default function VendorContactsTab() {
connectionId={connectionId}
onEdit={setEditingContact}
isSnapshotMode={isSnapshotMode}
canUpdate={canUpdateContact}
canDelete={canDeleteContact}
/>
))}
</Tbody>
</SortableTable>
{editingContact && !isSnapshotMode && canUpdateContact && (
{editingContact && !isSnapshotMode && editingContact.canUpdate && (
<EditContactDialog
contactId={editingContact.id}
contact={editingContact}
@@ -159,16 +160,8 @@ export default function VendorContactsTab() {
type ContactRowProps = {
contactKey: VendorContactsTabFragment_contact$key;
connectionId: string;
onEdit: (contact: {
id: string;
fullName?: string | null;
email?: string | null;
phone?: string | null;
role?: string | null;
}) => void;
onEdit: (contact: VendorContactsTabFragment_contact$data) => void;
isSnapshotMode: boolean;
canUpdate?: boolean;
canDelete?: boolean;
};
function ContactRow(props: ContactRowProps) {
@@ -182,7 +175,7 @@ function ContactRow(props: ContactRowProps) {
successMessage: __("Contact deleted successfully"),
errorMessage: __("Failed to delete contact"),
});
const hasAnyAction = props.canUpdate || props.canDelete;
const hasAnyAction = contact.canUpdate || contact.canDelete;
const handleDelete = () => {
confirm(
@@ -237,23 +230,15 @@ function ContactRow(props: ContactRowProps) {
{!props.isSnapshotMode && hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{props.canUpdate && (
{contact.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() =>
props.onEdit({
id: contact.id,
fullName: contact.fullName,
email: contact.email,
phone: contact.phone,
role: contact.role,
})
}
onClick={() => props.onEdit(contact)}
>
{__("Edit")}
</DropdownItem>
)}
{props.canDelete && (
{contact.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}

View File

@@ -15,7 +15,7 @@ import { PeopleSelectField } from "/components/form/PeopleSelectField";
import { ControlledField } from "/components/form/ControlledField";
import { CountriesField } from "/components/form/CountriesField";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { use, useMemo } from "react";
import { useMemo } from "react";
import { usePageTitle } from "@probo/hooks";
import { downloadFile, formatDate } from "@probo/helpers";
import { useFragment, graphql } from "react-relay";
@@ -25,11 +25,10 @@ import { EditBusinessAssociateAgreementDialog } from "../dialogs/EditBusinessAss
import { UploadDataPrivacyAgreementDialog } from "../dialogs/UploadDataPrivacyAgreementDialog";
import { DeleteDataPrivacyAgreementDialog } from "../dialogs/DeleteDataPrivacyAgreementDialog";
import { EditDataPrivacyAgreementDialog } from "../dialogs/EditDataPrivacyAgreementDialog";
import type { useVendorFormFragment$key } from "/__generated__/core/useVendorFormFragment.graphql";
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "/__generated__/core/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
import type { VendorOverviewTabDataPrivacyAgreementFragment$key } from "/__generated__/core/VendorOverviewTabDataPrivacyAgreementFragment.graphql";
import type { VendorCategory } from "@probo/vendors";
import { PermissionsContext } from "/providers/PermissionsContext";
import type { VendorGraphNodeQuery$data } from "/__generated__/core/VendorGraphNodeQuery.graphql";
const vendorBusinessAssociateAgreementFragment = graphql`
fragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {
@@ -40,6 +39,12 @@ const vendorBusinessAssociateAgreementFragment = graphql`
validFrom
validUntil
createdAt
canUpdate: permission(
action: "core:vendor-business-associate-agreement:update"
)
canDelete: permission(
action: "core:vendor-business-associate-agreement:delete"
)
}
}
`;
@@ -53,47 +58,18 @@ const vendorDataPrivacyAgreementFragment = graphql`
validFrom
validUntil
createdAt
canUpdate: permission(action: "core:vendor-data-privacy-agreement:update")
canDelete: permission(action: "core:vendor-data-privacy-agreement:delete")
}
}
`;
export default function VendorOverviewTab() {
const { vendor } = useOutletContext<{
vendor: useVendorFormFragment$key & { id: string; name: string };
}>();
const { vendor: vendorForBAA } = useOutletContext<{
vendor: VendorOverviewTabBusinessAssociateAgreementFragment$key &
VendorOverviewTabDataPrivacyAgreementFragment$key;
vendor: VendorGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { isAuthorized } = use(PermissionsContext);
const canUpdateVendor = isAuthorized("Vendor", "updateVendor");
const canUploadBAA = isAuthorized(
"Vendor",
"uploadVendorBusinessAssociateAgreement",
);
const canUpdateBAA = isAuthorized(
"Vendor",
"updateVendorBusinessAssociateAgreement",
);
const canDeleteBAA = isAuthorized(
"Vendor",
"deleteVendorBusinessAssociateAgreement",
);
const canUploadDPA = isAuthorized(
"Vendor",
"uploadVendorDataPrivacyAgreement",
);
const canUpdateDPA = isAuthorized(
"Vendor",
"updateVendorDataPrivacyAgreement",
);
const canDeleteDPA = isAuthorized(
"Vendor",
"deleteVendorDataPrivacyAgreement",
);
const vendorCategories: { value: VendorCategory; label: string }[] = [
{ value: "ANALYTICS", label: __("Analytics") },
{ value: "CLOUD_MONITORING", label: __("Cloud Monitoring") },
@@ -135,14 +111,14 @@ export default function VendorOverviewTab() {
const vendorWithBAA =
useFragment<VendorOverviewTabBusinessAssociateAgreementFragment$key>(
vendorBusinessAssociateAgreementFragment,
vendorForBAA,
vendor,
);
const businessAssociateAgreement = vendorWithBAA.businessAssociateAgreement;
const vendorWithDPA =
useFragment<VendorOverviewTabDataPrivacyAgreementFragment$key>(
vendorDataPrivacyAgreementFragment,
vendorForBAA,
vendor,
);
const dataPrivacyAgreement = vendorWithDPA.dataPrivacyAgreement;
@@ -168,11 +144,11 @@ export default function VendorOverviewTab() {
usePageTitle(vendor.name + " - " + __("Overview"));
const isFormDisabled = isSubmitting || isSnapshotMode || !canUpdateVendor;
const isFormDisabled = isSubmitting || isSnapshotMode || !vendor.canUpdate;
return (
<form
onSubmit={isSnapshotMode || !canUpdateVendor ? undefined : handleSubmit}
onSubmit={isSnapshotMode || !vendor.canUpdate ? undefined : handleSubmit}
className="space-y-12"
>
{/* Vendor Details */}
@@ -338,7 +314,7 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && canUpdateBAA && (
{!isSnapshotMode && businessAssociateAgreement.canUpdate && (
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
agreement={{
@@ -350,7 +326,7 @@ export default function VendorOverviewTab() {
<Button variant="quaternary" icon={IconPencil} />
</EditBusinessAssociateAgreementDialog>
)}
{!isSnapshotMode && canDeleteBAA && (
{!isSnapshotMode && businessAssociateAgreement.canDelete && (
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
fileName={businessAssociateAgreement.fileName}
@@ -362,7 +338,7 @@ export default function VendorOverviewTab() {
</>
) : (
!isSnapshotMode &&
canUploadBAA && (
vendor.canUploadBAA && (
<UploadBusinessAssociateAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -412,7 +388,7 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && canUpdateDPA && (
{!isSnapshotMode && dataPrivacyAgreement.canUpdate && (
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
agreement={{
@@ -424,7 +400,7 @@ export default function VendorOverviewTab() {
<Button variant="quaternary" icon={IconPencil} />
</EditDataPrivacyAgreementDialog>
)}
{!isSnapshotMode && canDeleteDPA && (
{!isSnapshotMode && dataPrivacyAgreement.canDelete && (
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
fileName={dataPrivacyAgreement.fileName}
@@ -436,7 +412,7 @@ export default function VendorOverviewTab() {
</>
) : (
!isSnapshotMode &&
canUploadDPA && (
vendor.canUploadDPA && (
<UploadDataPrivacyAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -455,7 +431,7 @@ export default function VendorOverviewTab() {
{/* Submit */}
{!isSnapshotMode && (
<div className="flex justify-end">
{isAuthorized("Vendor", "updateVendor") && (
{vendor.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>

View File

@@ -21,8 +21,9 @@ import type { VendorRiskAssessmentTabFragment_assessment$key } from "/__generate
import { SortableTable, SortableTh } from "/components/SortableTable";
import { CreateRiskAssessmentDialog } from "../dialogs/CreateRiskAssessmentDialog";
import clsx from "clsx";
import { use, useState } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
import { useState, type ComponentProps } from "react";
import type { VendorGraphNodeQuery$data } from "/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorRiskAssessmentTabQuery } from "/__generated__/core/VendorRiskAssessmentTabQuery.graphql";
const riskAssessmentsFragment = graphql`
fragment VendorRiskAssessmentTabFragment on Vendor
@@ -71,22 +72,17 @@ const riskAssessmentFragment = graphql`
export default function VendorRiskAssessmentTab() {
const { vendor } = useOutletContext<{
vendor: VendorRiskAssessmentTabFragment$key & { name: string; id: string };
vendor: VendorGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment(
riskAssessmentsFragment,
vendor,
);
const [data, refetch] = useRefetchableFragment<
VendorRiskAssessmentTabQuery,
VendorRiskAssessmentTabFragment$key
>(riskAssessmentsFragment, vendor);
const assessments = data.riskAssessments.edges.map((edge) => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [expanded, setExpanded] = useState<string | null>(null);
const { isAuthorized } = use(PermissionsContext);
const canCreateRiskAssessment = isAuthorized(
"Vendor",
"createVendorRiskAssessment",
);
usePageTitle(vendor.name + " - " + __("Risk Assessments"));
@@ -94,7 +90,7 @@ export default function VendorRiskAssessmentTab() {
return (
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
{__("No risk assessments found")}
{!isSnapshotMode && canCreateRiskAssessment && (
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}
@@ -112,7 +108,9 @@ export default function VendorRiskAssessmentTab() {
<div className="space-y-6 relative">
<div className="flex justify-end"></div>
<div className="overflow-x-auto">
<SortableTable refetch={refetch}>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="CREATED_AT">{__("Created At")}</SortableTh>
@@ -122,7 +120,7 @@ export default function VendorRiskAssessmentTab() {
</Tr>
</Thead>
<Tbody>
{!isSnapshotMode && canCreateRiskAssessment && (
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}

View File

@@ -19,14 +19,18 @@ import {
useConfirm,
} from "@probo/ui";
import { useFragment, useRefetchableFragment } from "react-relay";
import type { VendorServicesTabFragment_service$key } from "/__generated__/core/VendorServicesTabFragment_service.graphql";
import type {
VendorServicesTabFragment_service$data,
VendorServicesTabFragment_service$key,
} from "/__generated__/core/VendorServicesTabFragment_service.graphql";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { sprintf } from "@probo/helpers";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { CreateServiceDialog } from "../dialogs/CreateServiceDialog";
import { EditServiceDialog } from "../dialogs/EditServiceDialog";
import { use, useState } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
import { useState, type ComponentProps } from "react";
import type { VendorGraphNodeQuery$data } from "/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorServicesListQuery } from "/__generated__/core/VendorServicesListQuery.graphql";
export const vendorServicesFragment = graphql`
fragment VendorServicesTabFragment on Vendor
@@ -49,6 +53,8 @@ export const vendorServicesFragment = graphql`
edges {
node {
id
canUpdate: permission(action: "core:vendor-service:update")
canDelete: permission(action: "core:vendor-service:delete")
...VendorServicesTabFragment_service
}
}
@@ -63,6 +69,8 @@ const serviceFragment = graphql`
description
createdAt
updatedAt
canUpdate: permission(action: "core:vendor-service:update")
canDelete: permission(action: "core:vendor-service:delete")
}
`;
@@ -79,27 +87,22 @@ const deleteServiceMutation = graphql`
export default function VendorServicesTab() {
const { vendor } = useOutletContext<{
vendor: VendorServicesTabFragment$key & { name: string; id: string };
vendor: VendorGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment(
vendorServicesFragment,
vendor,
);
const [data, refetch] = useRefetchableFragment<
VendorServicesListQuery,
VendorServicesTabFragment$key
>(vendorServicesFragment, vendor);
const connectionId = data.services.__id;
const services = data.services.edges.map((edge) => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [editingService, setEditingService] = useState<{
id: string;
name: string;
description?: string | null;
} | null>(null);
const { isAuthorized } = use(PermissionsContext);
const canCreateService = isAuthorized("Vendor", "createVendorService");
const canUpdateService = isAuthorized("VendorService", "updateVendorService");
const canDeleteService = isAuthorized("VendorService", "deleteVendorService");
const hasAnyAction = canUpdateService || canDeleteService;
const [editingService, setEditingService] =
useState<VendorServicesTabFragment_service$data | null>(null);
const hasAnyAction = services.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(vendor.name + " - " + __("Services"));
@@ -109,14 +112,16 @@ export default function VendorServicesTab() {
title={__("Services")}
description={__("Manage services provided by this vendor.")}
>
{!isSnapshotMode && canCreateService && (
{!isSnapshotMode && vendor.canCreateService && (
<CreateServiceDialog vendorId={vendor.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
)}
</PageHeader>
<SortableTable refetch={refetch}>
<SortableTable
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
@@ -132,14 +137,12 @@ export default function VendorServicesTab() {
connectionId={connectionId}
onEdit={setEditingService}
isSnapshotMode={isSnapshotMode}
canUpdate={canUpdateService}
canDelete={canDeleteService}
/>
))}
</Tbody>
</SortableTable>
{editingService && !isSnapshotMode && canUpdateService && (
{editingService && !isSnapshotMode && editingService.canUpdate && (
<EditServiceDialog
serviceId={editingService.id}
service={editingService}
@@ -153,14 +156,8 @@ export default function VendorServicesTab() {
type ServiceRowProps = {
serviceKey: VendorServicesTabFragment_service$key;
connectionId: string;
onEdit: (service: {
id: string;
name: string;
description?: string | null;
}) => void;
onEdit: (service: VendorServicesTabFragment_service$data) => void;
isSnapshotMode: boolean;
canUpdate?: boolean;
canDelete?: boolean;
};
function ServiceRow(props: ServiceRowProps) {
@@ -174,7 +171,7 @@ function ServiceRow(props: ServiceRowProps) {
successMessage: __("Service deleted successfully"),
errorMessage: __("Failed to delete service"),
});
const hasAnyAction = props.canUpdate || props.canDelete;
const hasAnyAction = service.canUpdate || service.canDelete;
const handleDelete = () => {
confirm(
@@ -205,21 +202,15 @@ function ServiceRow(props: ServiceRowProps) {
{!props.isSnapshotMode && hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{props.canUpdate && (
{service.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() =>
props.onEdit({
id: service.id,
name: service.name,
description: service.description,
})
}
onClick={() => props.onEdit(service)}
>
{__("Edit")}
</DropdownItem>
)}
{props.canDelete && (
{service.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}