Fix vendor pages permissions handling
Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user