Add auditor role

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-12-10 14:41:54 +01:00
parent 9e5a775f66
commit f77676c88c
45 changed files with 1256 additions and 796 deletions

View File

@@ -461,7 +461,7 @@ export default function DocumentDetailPage(props: Props) {
]}
/>
<div className="flex gap-2">
{isDraft && (
{isDraft && isAuthorized("Document", "publishDocumentVersion") && (
<Button
onClick={handlePublish}
icon={IconCheckmark1}
@@ -499,16 +499,14 @@ export default function DocumentDetailPage(props: Props) {
{isDraft ? __("Edit draft document") : __("Create new draft")}
</DropdownItem>
)}
{isDraft && versions.length > 1 && (
isAuthorized("Document", "deleteDocument") && (
<DropdownItem
onClick={handleDeleteDraft}
icon={IconTrashCan}
disabled={isDeletingDraft}
>
{__("Delete draft document")}
</DropdownItem>
)
{isDraft && versions.length > 1 && isAuthorized("Document", "deleteDraftDocumentVersion") && (
<DropdownItem
onClick={handleDeleteDraft}
icon={IconTrashCan}
disabled={isDeletingDraft}
>
{__("Delete draft document")}
</DropdownItem>
)}
<DropdownItem
onClick={() => pdfDownloadDialogRef.current?.open()}
@@ -567,11 +565,13 @@ export default function DocumentDetailPage(props: Props) {
) : (
<div className="flex items-center gap-2">
<span>{document.title}</span>
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditingTitle(true)}
/>
{isAuthorized("Document", "updateDocument") && (
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditingTitle(true)}
/>
)}
</div>
)
}
@@ -616,7 +616,7 @@ export default function DocumentDetailPage(props: Props) {
/>
</EditablePropertyContent>
) : (
<ReadOnlyPropertyContent onEdit={() => setIsEditingOwner(true)}>
<ReadOnlyPropertyContent onEdit={() => setIsEditingOwner(true)} canEdit={isAuthorized("Document", "updateDocument")}>
<Badge variant="highlight" size="md" className="gap-2">
<Avatar name={currentVersion.owner?.fullName ?? ""} />
{currentVersion.owner?.fullName}
@@ -643,7 +643,7 @@ export default function DocumentDetailPage(props: Props) {
</ControlledField>
</EditablePropertyContent>
) : (
<ReadOnlyPropertyContent onEdit={() => setIsEditingType(true)}>
<ReadOnlyPropertyContent onEdit={() => setIsEditingType(true)} canEdit={isAuthorized("Document", "updateDocument")}>
<div className="text-sm text-txt-secondary">
{getDocumentTypeLabel(__, document.documentType)}
</div>
@@ -671,6 +671,7 @@ export default function DocumentDetailPage(props: Props) {
) : (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingClassification(true)}
canEdit={isAuthorized("Document", "updateDocument")}
>
<div className="text-sm text-txt-secondary">
{getDocumentClassificationLabel(__, currentVersion.classification)}
@@ -739,14 +740,16 @@ function EditablePropertyContent({
function ReadOnlyPropertyContent({
children,
onEdit,
canEdit = true,
}: {
children: React.ReactNode;
onEdit: () => void;
canEdit?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3">
{children}
<Button variant="quaternary" icon={IconPencil} onClick={onEdit} />
{canEdit && <Button variant="quaternary" icon={IconPencil} onClick={onEdit} />}
</div>
);
}

View File

@@ -178,6 +178,22 @@ export default function FrameworkControlPage({ queryRef }: Props) {
const [attachSnapshot, isAttachingSnapshot] = useMutation(attachSnapshotMutation);
const [deleteControl] = useMutation(deleteControlMutation);
const canLinkMeasure = isAuthorized("Control", "createControlMeasureMapping");
const canUnlinkMeasure = isAuthorized("Control", "deleteControlMeasureMapping");
const measuresReadOnly = !canLinkMeasure && !canUnlinkMeasure;
const canLinkDocument = isAuthorized("Control", "createControlDocumentMapping");
const canUnlinkDocument = isAuthorized("Control", "deleteControlDocumentMapping");
const documentsReadOnly = !canLinkDocument && !canUnlinkDocument;
const canLinkAudit = isAuthorized("Control", "createControlAuditMapping");
const canUnlinkAudit = isAuthorized("Control", "deleteControlAuditMapping");
const auditsReadOnly = !canLinkAudit && !canUnlinkAudit;
const canLinkSnapshot = isAuthorized("Control", "createControlSnapshotMapping");
const canUnlinkSnapshot = isAuthorized("Control", "deleteControlSnapshotMapping");
const snapshotsReadOnly = !canLinkSnapshot && !canUnlinkSnapshot;
const withErrorHandling = <T extends MutationParameters>(
mutationFn: (config: UseMutationConfig<T>) => void,
errorMessage: string
@@ -285,6 +301,7 @@ export default function FrameworkControlPage({ queryRef }: Props) {
onAttach={withErrorHandling(attachMeasure, __("Failed to link measure"))}
onDetach={withErrorHandling(detachMeasure, __("Failed to unlink measure"))}
disabled={isAttachingMeasure || isDetachingMeasure}
readOnly={measuresReadOnly}
/>
</div>
<div className="mb-4">
@@ -296,6 +313,7 @@ export default function FrameworkControlPage({ queryRef }: Props) {
onAttach={withErrorHandling(attachDocument, __("Failed to link document"))}
onDetach={withErrorHandling(detachDocument, __("Failed to unlink document"))}
disabled={isAttachingDocument || isDetachingDocument}
readOnly={documentsReadOnly}
/>
</div>
<div className="mb-4">
@@ -307,6 +325,7 @@ export default function FrameworkControlPage({ queryRef }: Props) {
onAttach={withErrorHandling(attachAudit, __("Failed to link audit"))}
onDetach={withErrorHandling(detachAudit, __("Failed to unlink audit"))}
disabled={isAttachingAudit || isDetachingAudit}
readOnly={auditsReadOnly}
/>
</div>
<div className="mb-4">
@@ -318,6 +337,7 @@ export default function FrameworkControlPage({ queryRef }: Props) {
onAttach={withErrorHandling(attachSnapshot, __("Failed to link snapshot"))}
onDetach={withErrorHandling(detachSnapshot, __("Failed to unlink snapshot"))}
disabled={isAttachingSnapshot || isDetachingSnapshot}
readOnly={snapshotsReadOnly}
/>
</div>
</div>

View File

@@ -26,10 +26,13 @@ import { MeasureBadge } from "@probo/ui/src/Molecules/Badge/MeasureBadge";
import { useTranslate } from "@probo/i18n";
import {
ConnectionHandler,
graphql,
type PreloadedQuery,
useLazyLoadQuery,
usePreloadedQuery,
} from "react-relay";
import type { MeasureGraphNodeQuery } from "/hooks/graph/__generated__/MeasureGraphNodeQuery.graphql";
import type { MeasureDetailPageTasksCountQuery } from "./__generated__/MeasureDetailPageTasksCountQuery.graphql";
import {
MeasureConnectionKey,
measureNodeQuery,
@@ -43,9 +46,30 @@ import {
sprintf,
} from "@probo/helpers";
import MeasureFormDialog from "./dialog/MeasureFormDialog";
import { use } from "react";
import { Suspense, use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
const tasksCountQuery = graphql`
query MeasureDetailPageTasksCountQuery($measureId: ID!) {
node(id: $measureId) {
... on Measure {
tasks(first: 0) {
totalCount
}
}
}
}
`;
function TasksCountBadge({ measureId }: { measureId: string }) {
const data = useLazyLoadQuery<MeasureDetailPageTasksCountQuery>(
tasksCountQuery,
{ measureId }
);
const count = data.node?.tasks?.totalCount ?? 0;
return <TabBadge>{count}</TabBadge>;
}
type Props = {
queryRef: PreloadedQuery<MeasureGraphNodeQuery>;
};
@@ -67,7 +91,7 @@ export default function MeasureDetailPage(props: Props) {
);
}
const tasksCount = measure.tasksInfos?.totalCount ?? 0;
const canViewTasks = isAuthorized("Measure", "listTasks");
const evidencesCount = measure.evidencesInfos?.totalCount ?? 0;
const controlsCount = measure.controlsInfos?.totalCount ?? 0;
const risksCount = measure.risksInfos?.totalCount ?? 0;
@@ -160,13 +184,13 @@ export default function MeasureDetailPage(props: Props) {
</Select>
</>
)}
<ActionDropdown variant="secondary">
{isAuthorized("Measure", "deleteMeasure") && (
{isAuthorized("Measure", "deleteMeasure") && (
<ActionDropdown variant="secondary">
<DropdownItem variant="danger" icon={IconTrashCan} onClick={onDelete}>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</ActionDropdown>
)}
</PageHeader>
<Tabs>
@@ -177,13 +201,17 @@ export default function MeasureDetailPage(props: Props) {
{__("Evidences")}
<TabBadge>{evidencesCount}</TabBadge>
</TabLink>
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/tasks`}
>
<IconCheckmark1 size={20} />
{__("Tasks")}
<TabBadge>{tasksCount}</TabBadge>
</TabLink>
{canViewTasks && (
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/tasks`}
>
<IconCheckmark1 size={20} />
{__("Tasks")}
<Suspense fallback={<TabBadge>-</TabBadge>}>
<TasksCountBadge measureId={measureId} />
</Suspense>
</TabLink>
)}
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/controls`}
>

View File

@@ -0,0 +1,143 @@
/**
* @generated SignedSource<<c14e7d1b8a5a8d23dbe52637610f13f7>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MeasureDetailPageTasksCountQuery$variables = {
measureId: string;
};
export type MeasureDetailPageTasksCountQuery$data = {
readonly node: {
readonly tasks?: {
readonly totalCount: number;
};
};
};
export type MeasureDetailPageTasksCountQuery = {
response: MeasureDetailPageTasksCountQuery$data;
variables: MeasureDetailPageTasksCountQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "measureId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "measureId"
}
],
v2 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 0
}
],
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "tasks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
}
],
"storageKey": "tasks(first:0)"
}
],
"type": "Measure",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeasureDetailPageTasksCountQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeasureDetailPageTasksCountQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "36a1d5cf32d07ed5d76e5e64e7f19005",
"id": null,
"metadata": {},
"name": "MeasureDetailPageTasksCountQuery",
"operationKind": "query",
"text": "query MeasureDetailPageTasksCountQuery(\n $measureId: ID!\n) {\n node(id: $measureId) {\n __typename\n ... on Measure {\n tasks(first: 0) {\n totalCount\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "f0a8dde53529b525f3fe9aa35dee9e8a";
export default node;

View File

@@ -3,6 +3,8 @@ import { useOutletContext } from "react-router";
import { LinkedControlsCard } from "/components/controls/LinkedControlsCard";
import type { MeasureControlsTabFragment$key } from "./__generated__/MeasureControlsTabFragment.graphql";
import { useMutationWithIncrement } from "/hooks/useMutationWithIncrement";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const controlsFragment = graphql`
fragment MeasureControlsTabFragment on Measure
@@ -69,6 +71,11 @@ export default function MeasureControlsTab() {
const [data, refetch] = useRefetchableFragment(controlsFragment, measure);
const connectionId = data.controls.__id;
const controls = data.controls?.edges?.map((edge) => edge.node) ?? [];
const { isAuthorized } = use(PermissionsContext);
const canLinkControl = isAuthorized("Control", "createControlMeasureMapping");
const canUnlinkControl = isAuthorized("Control", "deleteControlMeasureMapping");
const readOnly = !canLinkControl && !canUnlinkControl;
const incrementOptions = {
id: data.id,
@@ -99,6 +106,7 @@ export default function MeasureControlsTab() {
params={{ measureId: data.id }}
connectionId={connectionId}
refetch={refetch}
readOnly={readOnly}
/>
);
}

View File

@@ -29,10 +29,11 @@ import { fileSize, fileType, sprintf, formatDate } from "@probo/helpers";
import { EvidencePreviewDialog } from "../dialog/EvidencePreviewDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { CreateEvidenceDialog } from "../dialog/CreateEvidenceDialog";
import { useState } from "react";
import { use, useState } from "react";
import { EvidenceDownloadDialog } from "../dialog/EvidenceDownloadDialog";
import { updateStoreCounter } from "/hooks/useMutationWithIncrement";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { PermissionsContext } from "/providers/PermissionsContext";
export const evidencesFragment = graphql`
fragment MeasureEvidencesTabFragment on Measure
@@ -107,6 +108,10 @@ export default function MeasureEvidencesTab() {
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const isSnapshotMode = Boolean(snapshotId);
const { isAuthorized } = use(PermissionsContext);
const canAddEvidence = isAuthorized("Measure", "uploadMeasureEvidence");
const canDeleteEvidence = isAuthorized("Evidence", "deleteEvidence");
usePageTitle(measure.name + " - " + __("Evidences"));
@@ -131,10 +136,11 @@ export default function MeasureEvidencesTab() {
organizationId={organizationId}
connectionId={connectionId}
hideActions={isSnapshotMode}
canDelete={canDeleteEvidence}
snapshotId={snapshotId}
/>
))}
{!isSnapshotMode && (
{!isSnapshotMode && canAddEvidence && (
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
@@ -158,7 +164,7 @@ export default function MeasureEvidencesTab() {
filename={evidence.file?.fileName || ""}
/>
)}
{!isSnapshotMode && (
{!isSnapshotMode && canAddEvidence && (
<CreateEvidenceDialog
ref={dialogRef}
measureId={measure.id}
@@ -175,6 +181,7 @@ function EvidenceRow(props: {
organizationId: string;
connectionId: string;
hideActions?: boolean;
canDelete?: boolean;
snapshotId?: string;
}) {
const evidence = useFragment(evidenceFragment, props.evidenceKey);
@@ -249,14 +256,16 @@ function EvidenceRow(props: {
<IconArrowInbox size={16} />
{__("Download")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
{props.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</div>
)}

View File

@@ -3,6 +3,8 @@ import type { MeasureRisksTabFragment$key } from "./__generated__/MeasureRisksTa
import { useOutletContext } from "react-router";
import { LinkedRisksCard } from "/components/risks/LinkedRisksCard";
import { useMutationWithIncrement } from "/hooks/useMutationWithIncrement";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const risksFragment = graphql`
fragment MeasureRisksTabFragment on Measure {
@@ -53,6 +55,11 @@ export default function MeasureRisksTab() {
const data = useFragment(risksFragment, measure);
const connectionId = data.risks.__id;
const risks = data.risks?.edges?.map((edge) => edge.node) ?? [];
const { isAuthorized } = use(PermissionsContext);
const canLinkRisk = isAuthorized("Risk", "createRiskMeasureMapping");
const canUnlinkRisk = isAuthorized("Risk", "deleteRiskMeasureMapping");
const readOnly = !canLinkRisk && !canUnlinkRisk;
const incrementOptions = {
id: data.id,
@@ -82,6 +89,7 @@ export default function MeasureRisksTab() {
onDetach={detachRisk}
params={{ measureId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -1,26 +1,31 @@
import { graphql } from "relay-runtime";
import type { MeasureTasksTabFragment$key } from "./__generated__/MeasureTasksTabFragment.graphql";
import type { MeasureTasksTabQuery } from "./__generated__/MeasureTasksTabQuery.graphql";
import { useOutletContext } from "react-router";
import { useFragment } from "react-relay";
import { useLazyLoadQuery } from "react-relay";
import TasksCard from "/components/tasks/TasksCard";
import { Button, IconPlusLarge } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import TaskFormDialog from "/components/tasks/TaskFormDialog";
export const tasksFragment = graphql`
fragment MeasureTasksTabFragment on Measure {
tasks(first: 100) @connection(key: "Measure__tasks") {
__id
edges {
node {
id
name
state
description
...TaskFormDialogFragment
assignedTo {
id
fullName
const tasksQuery = graphql`
query MeasureTasksTabQuery($measureId: ID!) {
node(id: $measureId) {
... on Measure {
id
tasks(first: 100) @connection(key: "Measure__tasks") {
__id
edges {
node {
id
name
state
description
...TaskFormDialogFragment
assignedTo {
id
fullName
}
}
}
}
}
@@ -31,11 +36,17 @@ export const tasksFragment = graphql`
export default function MeasureTasksTab() {
const { __ } = useTranslate();
const { measure } = useOutletContext<{
measure: MeasureTasksTabFragment$key & { id: string };
measure: { id: string };
}>();
const data = useFragment(tasksFragment, measure);
const connectionId = data.tasks.__id;
const tasks = data.tasks?.edges?.map((edge) => edge.node) ?? [];
const data = useLazyLoadQuery<MeasureTasksTabQuery>(tasksQuery, {
measureId: measure.id,
});
const node = data.node;
if (!node || !node.tasks) {
return null;
}
const connectionId = node.tasks.__id;
const tasks = node.tasks.edges?.map((edge) => edge.node) ?? [];
return (
<div className="relative">

View File

@@ -1,201 +0,0 @@
/**
* @generated SignedSource<<cfddd118e2b6db135ad87fe157c89151>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type TaskState = "DONE" | "TODO";
import { FragmentRefs } from "relay-runtime";
export type MeasureTasksTabFragment$data = {
readonly tasks: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly assignedTo: {
readonly fullName: string;
readonly id: string;
} | null | undefined;
readonly description: string | null | undefined;
readonly id: string;
readonly name: string;
readonly state: TaskState;
readonly " $fragmentSpreads": FragmentRefs<"TaskFormDialogFragment">;
};
}>;
};
readonly " $fragmentType": "MeasureTasksTabFragment";
};
export type MeasureTasksTabFragment$key = {
readonly " $data"?: MeasureTasksTabFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"MeasureTasksTabFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"tasks"
]
}
]
},
"name": "MeasureTasksTabFragment",
"selections": [
{
"alias": "tasks",
"args": null,
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "__Measure__tasks_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"args": null,
"kind": "FragmentSpread",
"name": "TaskFormDialogFragment"
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
}
],
"type": "Measure",
"abstractKey": null
};
})();
(node as any).hash = "40d3211a52860f68da7c89ba9123af61";
export default node;

View File

@@ -0,0 +1,370 @@
/**
* @generated SignedSource<<f2e7ca902ae7ee08f260f27a0fefa043>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type TaskState = "DONE" | "TODO";
export type MeasureTasksTabQuery$variables = {
measureId: string;
};
export type MeasureTasksTabQuery$data = {
readonly node: {
readonly id?: string;
readonly tasks?: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly assignedTo: {
readonly fullName: string;
readonly id: string;
} | null | undefined;
readonly description: string | null | undefined;
readonly id: string;
readonly name: string;
readonly state: TaskState;
readonly " $fragmentSpreads": FragmentRefs<"TaskFormDialogFragment">;
};
}>;
};
};
};
export type MeasureTasksTabQuery = {
response: MeasureTasksTabQuery$data;
variables: MeasureTasksTabQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "measureId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "measureId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "state",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "assignedTo",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v9 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
v10 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
},
v11 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MeasureTasksTabQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"kind": "InlineFragment",
"selections": [
(v2/*: any*/),
{
"alias": "tasks",
"args": null,
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "__Measure__tasks_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "TaskFormDialogFragment"
},
(v6/*: any*/),
(v7/*: any*/)
],
"storageKey": null
},
(v8/*: any*/)
],
"storageKey": null
},
(v9/*: any*/),
(v10/*: any*/)
],
"storageKey": null
}
],
"type": "Measure",
"abstractKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MeasureTasksTabQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v7/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v11/*: any*/),
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "tasks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deadline",
"storageKey": null
},
(v6/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
},
(v7/*: any*/)
],
"storageKey": null
},
(v8/*: any*/)
],
"storageKey": null
},
(v9/*: any*/),
(v10/*: any*/)
],
"storageKey": "tasks(first:100)"
},
{
"alias": null,
"args": (v11/*: any*/),
"filters": null,
"handle": "connection",
"key": "Measure__tasks",
"kind": "LinkedHandle",
"name": "tasks"
}
],
"type": "Measure",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "bc71d01128d96026a1e630b96e649409",
"id": null,
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"node",
"tasks"
]
}
]
},
"name": "MeasureTasksTabQuery",
"operationKind": "query",
"text": "query MeasureTasksTabQuery(\n $measureId: ID!\n) {\n node(id: $measureId) {\n __typename\n ... on Measure {\n id\n tasks(first: 100) {\n edges {\n node {\n id\n name\n state\n description\n ...TaskFormDialogFragment\n assignedTo {\n id\n fullName\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n id\n }\n}\n\nfragment TaskFormDialogFragment on Task {\n id\n description\n name\n state\n timeEstimate\n deadline\n assignedTo {\n id\n }\n measure {\n id\n }\n}\n"
}
};
})();
(node as any).hash = "12445eb032af3bc00fea9edce6f427e2";
export default node;

View File

@@ -1,6 +1,6 @@
import { useTranslate } from "@probo/i18n";
import { Button, Card, IconCheckmark1 } from "@probo/ui";
import type { PropsWithChildren } from "react";
import { use, type PropsWithChildren } from "react";
import z from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { ControlledField } from "/components/form/ControlledField";
@@ -11,6 +11,7 @@ import { useOutletContext } from "react-router";
import { updatePeopleMutation } from "/hooks/graph/PeopleGraph";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import type { PeopleGraphUpdateMutation } from "/hooks/graph/__generated__/PeopleGraphUpdateMutation.graphql";
import { PermissionsContext } from "/providers/PermissionsContext";
const schema = z.object({
kind: z.enum(peopleRoles),
@@ -21,6 +22,8 @@ export default function PeopleRoleTab() {
people: PeopleGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { isAuthorized } = use(PermissionsContext);
const canUpdatePeople = isAuthorized("People", "updatePeople");
const { control, formState, handleSubmit, reset } = useFormWithSchema(
schema,
{
@@ -65,6 +68,7 @@ export default function PeopleRoleTab() {
name="kind"
type="select"
label={__("Role")}
disabled={!canUpdatePeople}
>
{getRoles(__).map((role) => (
<Option key={role.value} value={role.value}>
@@ -93,13 +97,15 @@ export default function PeopleRoleTab() {
</ul>
</div>
</Card>
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={isMutating}>
{__("Update")}
</Button>
)}
</div>
{canUpdatePeople && (
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={isMutating}>
{__("Update")}
</Button>
)}
</div>
)}
</form>
);
}

View File

@@ -3,6 +3,8 @@ import { useOutletContext } from "react-router";
import { LinkedDocumentsCard } from "/components/documents/LinkedDocumentsCard";
import type { RiskDocumentsTabFragment$key } from "./__generated__/RiskDocumentsTabFragment.graphql";
import { useMutationWithIncrement } from "/hooks/useMutationWithIncrement";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const documentsFragment = graphql`
fragment RiskDocumentsTabFragment on Risk {
@@ -53,6 +55,11 @@ export default function RiskDocumentsTab() {
const data = useFragment(documentsFragment, risk);
const connectionId = data.documents.__id;
const documents = data.documents?.edges?.map((edge) => edge.node) ?? [];
const { isAuthorized } = use(PermissionsContext);
const canLinkDocument = isAuthorized("Risk", "createRiskDocumentMapping");
const canUnlinkDocument = isAuthorized("Risk", "deleteRiskDocumentMapping");
const readOnly = !canLinkDocument && !canUnlinkDocument;
const incrementOptions = {
id: data.id,
@@ -82,6 +89,7 @@ export default function RiskDocumentsTab() {
onDetach={detachDocument}
params={{ riskId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -3,6 +3,8 @@ import type { RiskMeasuresTabFragment$key } from "./__generated__/RiskMeasuresTa
import { useOutletContext } from "react-router";
import { LinkedMeasuresCard } from "/components/measures/LinkedMeasuresCard";
import { useMutationWithIncrement } from "/hooks/useMutationWithIncrement";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const measuresFragment = graphql`
fragment RiskMeasuresTabFragment on Risk {
@@ -53,6 +55,12 @@ export default function RiskMeasuresTab() {
const data = useFragment(measuresFragment, risk);
const connectionId = data.measures.__id;
const measures = data.measures?.edges?.map((edge) => edge.node) ?? [];
const { isAuthorized } = use(PermissionsContext);
const canLinkMeasure = isAuthorized("Risk", "createRiskMeasureMapping");
const canUnlinkMeasure = isAuthorized("Risk", "deleteRiskMeasureMapping");
const readOnly = !canLinkMeasure && !canUnlinkMeasure;
const incrementOptions = {
id: data.id,
node: "measures(first:0)",
@@ -81,6 +89,7 @@ export default function RiskMeasuresTab() {
onDetach={detachMeasure}
params={{ riskId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -3,6 +3,8 @@ import type { RiskObligationsTabFragment$key } from "./__generated__/RiskObligat
import { useOutletContext } from "react-router";
import { LinkedObligationsCard } from "/components/obligations/LinkedObligationsCard";
import { useMutationWithIncrement } from "/hooks/useMutationWithIncrement";
import { use } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const obligationsFragment = graphql`
fragment RiskObligationsTabFragment on Risk {
@@ -53,6 +55,12 @@ export default function RiskObligationsTab() {
const data = useFragment(obligationsFragment, risk);
const connectionId = data.obligations.__id;
const obligations = data.obligations?.edges?.map((edge) => edge.node) ?? [];
const { isAuthorized } = use(PermissionsContext);
const canLinkObligation = isAuthorized("Risk", "createRiskObligationMapping");
const canUnlinkObligation = isAuthorized("Risk", "deleteRiskObligationMapping");
const readOnly = !canLinkObligation && !canUnlinkObligation;
const incrementOptions = {
id: data.id,
node: "obligations(first:0)",
@@ -82,6 +90,7 @@ export default function RiskObligationsTab() {
params={{ riskId: data.id }}
connectionId={connectionId}
variant="table"
readOnly={readOnly}
/>
);
}

View File

@@ -535,6 +535,7 @@ function MembershipRowContent(props: {
{availableRoles.includes("OWNER") && <Option value="OWNER">{__("Owner")}</Option>}
{availableRoles.includes("ADMIN") && <Option value="ADMIN">{__("Admin")}</Option>}
{availableRoles.includes("VIEWER") && <Option value="VIEWER">{__("Viewer")}</Option>}
{availableRoles.includes("AUDITOR") && <Option value="AUDITOR">{__("Auditor")}</Option>}
{availableRoles.includes("EMPLOYEE") && <Option value="EMPLOYEE">{__("Employee")}</Option>}
</Select>
</Field>
@@ -549,6 +550,9 @@ function MembershipRowContent(props: {
{selectedRole === "VIEWER" && (
<p>{__("Read-only access")}</p>
)}
{selectedRole === "AUDITOR" && (
<p>{__("Read-only access without settings, tasks and meetings")}</p>
)}
{selectedRole === "EMPLOYEE" && (
<p>{__("Access to employee page")}</p>
)}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<0bd95d20e79294c530610625c86e88d7>>
* @generated SignedSource<<dd7d5d34935f87fcfb658ca6cfc5e794>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,7 +10,7 @@
import { ReaderFragment } from 'relay-runtime';
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
export type MembershipRole = "ADMIN" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER";
import { FragmentRefs } from "relay-runtime";
export type MembersSettingsTabInvitationsFragment$data = {
readonly id: string;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<8fcd99714c4bf7dba138fcb0de398a3a>>
* @generated SignedSource<<c3bf3676d3f8688315fc342efcf54547>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,7 +9,7 @@
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type MembershipRole = "ADMIN" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type UserAuthMethod = "PASSWORD" | "SAML";
import { FragmentRefs } from "relay-runtime";
export type MembersSettingsTabMembershipsFragment$data = {

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<9aaf763355340403cfd0c9666b61be19>>
* @generated SignedSource<<e40f8c235af2bfed3a33bdf0e5a3c24b>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,7 +9,7 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MembershipRole = "ADMIN" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER";
export type UpdateMembershipInput = {
memberId: string;
organizationId: string;

View File

@@ -91,11 +91,13 @@ export default function VendorDetailPage(props: Props) {
</div>
{!isSnapshotMode && (
<div className="flex gap-2 items-center">
<ImportAssessmentDialog vendorId={vendor.id!}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
{isAuthorized("Vendor", "assessVendor") && (
<ImportAssessmentDialog vendorId={vendor.id!}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{isAuthorized("Vendor", "deleteVendor") && (
<ActionDropdown variant="secondary">
<DropdownItem

View File

@@ -11,14 +11,15 @@ import {
import { Controller } from "react-hook-form";
import { useVendorForm } from "/hooks/forms/useVendorForm";
import type { useVendorFormFragment$key } from "/hooks/forms/__generated__/useVendorFormFragment.graphql";
import { useOutletContext } from "react-router";
import { useOutletContext, useParams } from "react-router";
import {
certificationCategoryLabel,
certifications,
objectEntries,
} from "@probo/helpers";
import { useRef, useState } from "react";
import { use, useRef, useState } from "react";
import clsx from "clsx";
import { PermissionsContext } from "/providers/PermissionsContext";
/**
* Vendor certifications tab
@@ -29,9 +30,13 @@ export default function VendorCertificationsTab() {
}>();
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={handleSubmit}>
<form className="space-y-4" onSubmit={!isSnapshotMode && canUpdateVendor ? handleSubmit : undefined}>
<Card padded>
<Controller
control={control}
@@ -40,13 +45,16 @@ export default function VendorCertificationsTab() {
<Certifications
onValueChange={field.onChange}
value={field.value ?? []}
readOnly={isSnapshotMode || !canUpdateVendor}
/>
)}
/>
</Card>
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
</div>
{!isSnapshotMode && canUpdateVendor && (
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
</div>
)}
</form>
);
}
@@ -54,6 +62,7 @@ export default function VendorCertificationsTab() {
type CertificationsProps = {
value: string[];
onValueChange: (value: string[]) => void;
readOnly?: boolean;
};
/**
@@ -94,31 +103,37 @@ function Certifications(props: CertificationsProps) {
<div className="flex flex-wrap gap-2">
{certifications.map((certification) => (
<Badge asChild size="md" key={certification}>
<button
onClick={() => removeCertificate(certification)}
type="button"
className={clsx(
"hover:bg-subtle-hover cursor-pointer",
animateBadge.current &&
"starting:opacity-0 starting:w-0 w-max transition-all duration-500 starting:bg-accent"
)}
>
{certification}
<div className="w-0 overflow-hidden group-hover:w-4 duration-200">
<IconCrossLargeX size={12} />
</div>
</button>
{props.readOnly ? (
<span>{certification}</span>
) : (
<button
onClick={() => removeCertificate(certification)}
type="button"
className={clsx(
"hover:bg-subtle-hover cursor-pointer",
animateBadge.current &&
"starting:opacity-0 starting:w-0 w-max transition-all duration-500 starting:bg-accent"
)}
>
{certification}
<div className="w-0 overflow-hidden group-hover:w-4 duration-200">
<IconCrossLargeX size={12} />
</div>
</button>
)}
</Badge>
))}
</div>
</div>
))}
<CertificationInput
certifications={categorizedCertifications.filter(
(c) => !props.value.includes(c)
)}
onAdd={addCertificate}
/>
{!props.readOnly && (
<CertificationInput
certifications={categorizedCertifications.filter(
(c) => !props.value.includes(c)
)}
onAdd={addCertificate}
/>
)}
</div>
);
}

View File

@@ -20,6 +20,8 @@ import type { VendorComplianceTabFragment_report$key } from "./__generated__/Ven
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";
export const complianceReportsFragment = graphql`
fragment VendorComplianceTabFragment on Vendor
@@ -103,6 +105,9 @@ export default function VendorComplianceTab() {
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");
usePageTitle(vendor.name + " - " + __("Compliance reports"));
@@ -130,7 +135,7 @@ export default function VendorComplianceTab() {
return (
<div className="space-y-6">
{!isSnapshotMode && (
{!isSnapshotMode && canUploadReport && (
<Dropzone
description={__("Only PDF files up to 10MB are allowed")}
isUploading={isMutating}
@@ -148,7 +153,7 @@ export default function VendorComplianceTab() {
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
<Th>{__("Valid until")}</Th>
<Th>{__("File size")}</Th>
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
{!isSnapshotMode && canDeleteReport && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -158,6 +163,7 @@ export default function VendorComplianceTab() {
reportKey={report}
connectionId={connectionId}
isSnapshotMode={isSnapshotMode}
canDelete={canDeleteReport}
/>
))}
</Tbody>
@@ -170,6 +176,7 @@ type ReportRowProps = {
reportKey: VendorComplianceTabFragment_report$key;
connectionId: string;
isSnapshotMode: boolean;
canDelete?: boolean;
};
function ReportRow(props: ReportRowProps) {
@@ -212,7 +219,7 @@ function ReportRow(props: ReportRowProps) {
<Td>{formatDate(report.reportDate)}</Td>
<Td>{formatDate(report.validUntil)}</Td>
<Td>{fileSize(__, report.file?.size)}</Td>
{!props.isSnapshotMode && (
{!props.isSnapshotMode && props.canDelete && (
<Td width={50} className="text-end">
<ActionDropdown>
<DropdownItem

View File

@@ -25,7 +25,8 @@ import { sprintf } from "@probo/helpers";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { CreateContactDialog } from "../dialogs/CreateContactDialog";
import { EditContactDialog } from "../dialogs/EditContactDialog";
import { useState } from "react";
import { use, useState } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const vendorContactsFragment = graphql`
fragment VendorContactsTabFragment on Vendor
@@ -98,6 +99,11 @@ export default function VendorContactsTab() {
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;
usePageTitle(vendor.name + " - " + __("Contacts"));
@@ -107,7 +113,7 @@ export default function VendorContactsTab() {
title={__("Contacts")}
description={__("Manage vendor contacts and their information.")}
>
{!isSnapshotMode && (
{!isSnapshotMode && canCreateContact && (
<CreateContactDialog
vendorId={vendor.id}
connectionId={connectionId}
@@ -124,7 +130,7 @@ export default function VendorContactsTab() {
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
<Th>{__("Phone")}</Th>
<Th>{__("Role")}</Th>
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -135,12 +141,14 @@ export default function VendorContactsTab() {
connectionId={connectionId}
onEdit={setEditingContact}
isSnapshotMode={isSnapshotMode}
canUpdate={canUpdateContact}
canDelete={canDeleteContact}
/>
))}
</Tbody>
</SortableTable>
{editingContact && !isSnapshotMode && (
{editingContact && !isSnapshotMode && canUpdateContact && (
<EditContactDialog
contactId={editingContact.id}
contact={editingContact}
@@ -162,6 +170,8 @@ type ContactRowProps = {
role?: string | null;
}) => void;
isSnapshotMode: boolean;
canUpdate?: boolean;
canDelete?: boolean;
};
function ContactRow(props: ContactRowProps) {
@@ -175,6 +185,7 @@ function ContactRow(props: ContactRowProps) {
successMessage: __("Contact deleted successfully"),
errorMessage: __("Failed to delete contact"),
});
const hasAnyAction = props.canUpdate || props.canDelete;
const handleDelete = () => {
confirm(
@@ -226,28 +237,32 @@ function ContactRow(props: ContactRowProps) {
)}
</Td>
<Td>{contact.role || __("—")}</Td>
{!props.isSnapshotMode && (
{!props.isSnapshotMode && hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit({
id: contact.id,
fullName: contact.fullName,
email: contact.email,
phone: contact.phone,
role: contact.role,
})}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
{props.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit({
id: contact.id,
fullName: contact.fullName,
email: contact.email,
phone: contact.phone,
role: contact.role,
})}
>
{__("Edit")}
</DropdownItem>
)}
{props.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}

View File

@@ -59,6 +59,13 @@ export default function VendorOverviewTab() {
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") },
@@ -128,8 +135,10 @@ export default function VendorOverviewTab() {
usePageTitle(vendor.name + " - " + __("Overview"));
const isFormDisabled = isSubmitting || isSnapshotMode || !canUpdateVendor;
return (
<form onSubmit={isSnapshotMode ? undefined : handleSubmit} className="space-y-12">
<form onSubmit={isSnapshotMode || !canUpdateVendor ? undefined : handleSubmit} className="space-y-12">
{/* Vendor Details */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Vendor details")}</h2>
@@ -139,14 +148,14 @@ export default function VendorOverviewTab() {
label={__("Name")}
type="text"
error={errors.name?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
<Field
{...register("description")}
label={__("Description")}
type="textarea"
error={errors.description?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
<ControlledField
control={control}
@@ -155,7 +164,7 @@ export default function VendorOverviewTab() {
label={__("Category")}
placeholder={__("Select a category")}
error={errors.category?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
>
{vendorCategories.map((category) => (
<Option key={category.value} value={category.value}>
@@ -168,21 +177,21 @@ export default function VendorOverviewTab() {
label={__("Legal name")}
type="text"
error={errors.legalName?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
<Field
{...register("headquarterAddress")}
label={__("Headquarter address")}
type="textarea"
error={errors.headquarterAddress?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
<Field
{...register("websiteUrl")}
label={__("Website URL")}
type="text"
error={errors.websiteUrl?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
</Card>
</div>
@@ -193,7 +202,7 @@ export default function VendorOverviewTab() {
<CountriesField
control={control}
name="countries"
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
</Card>
</div>
@@ -208,7 +217,7 @@ export default function VendorOverviewTab() {
name="businessOwnerId"
label={__("Business owner")}
error={errors.businessOwnerId?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
optional={true}
/>
<PeopleSelectField
@@ -217,7 +226,7 @@ export default function VendorOverviewTab() {
name="securityOwnerId"
label={__("Security owner")}
error={errors.securityOwnerId?.message}
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
optional={true}
/>
</Card>
@@ -246,7 +255,7 @@ export default function VendorOverviewTab() {
type="text"
placeholder="https://..."
variant="ghost"
disabled={isSubmitting || isSnapshotMode}
disabled={isFormDisabled}
/>
</div>
))}
@@ -287,30 +296,30 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && (
<>
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
agreement={{
validFrom: businessAssociateAgreement.validFrom,
validUntil: businessAssociateAgreement.validUntil,
}}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconPencil} />
</EditBusinessAssociateAgreementDialog>
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
fileName={businessAssociateAgreement.fileName}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconTrashCan} />
</DeleteBusinessAssociateAgreementDialog>
</>
{!isSnapshotMode && canUpdateBAA && (
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
agreement={{
validFrom: businessAssociateAgreement.validFrom,
validUntil: businessAssociateAgreement.validUntil,
}}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconPencil} />
</EditBusinessAssociateAgreementDialog>
)}
{!isSnapshotMode && canDeleteBAA && (
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
fileName={businessAssociateAgreement.fileName}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconTrashCan} />
</DeleteBusinessAssociateAgreementDialog>
)}
</>
) : (
!isSnapshotMode && (
!isSnapshotMode && canUploadBAA && (
<UploadBusinessAssociateAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -354,30 +363,30 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && (
<>
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
agreement={{
validFrom: dataPrivacyAgreement.validFrom,
validUntil: dataPrivacyAgreement.validUntil,
}}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconPencil} />
</EditDataPrivacyAgreementDialog>
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
fileName={dataPrivacyAgreement.fileName}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconTrashCan} />
</DeleteDataPrivacyAgreementDialog>
</>
{!isSnapshotMode && canUpdateDPA && (
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
agreement={{
validFrom: dataPrivacyAgreement.validFrom,
validUntil: dataPrivacyAgreement.validUntil,
}}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconPencil} />
</EditDataPrivacyAgreementDialog>
)}
{!isSnapshotMode && canDeleteDPA && (
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
fileName={dataPrivacyAgreement.fileName}
onSuccess={() => window.location.reload()}
>
<Button variant="quaternary" icon={IconTrashCan} />
</DeleteDataPrivacyAgreementDialog>
)}
</>
) : (
!isSnapshotMode && (
!isSnapshotMode && canUploadDPA && (
<UploadDataPrivacyAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}

View File

@@ -21,7 +21,8 @@ import type { VendorRiskAssessmentTabFragment_assessment$key } from "./__generat
import { SortableTable, SortableTh } from "/components/SortableTable";
import { CreateRiskAssessmentDialog } from "../dialogs/CreateRiskAssessmentDialog";
import clsx from "clsx";
import { useState } from "react";
import { use, useState } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
const riskAssessmentsFragment = graphql`
fragment VendorRiskAssessmentTabFragment on Vendor
@@ -81,6 +82,8 @@ export default function VendorRiskAssessmentTab() {
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"));
@@ -88,7 +91,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 && (
{!isSnapshotMode && canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}
@@ -116,7 +119,7 @@ export default function VendorRiskAssessmentTab() {
</Tr>
</Thead>
<Tbody>
{!isSnapshotMode && (
{!isSnapshotMode && canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}

View File

@@ -25,7 +25,8 @@ import { sprintf } from "@probo/helpers";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { CreateServiceDialog } from "../dialogs/CreateServiceDialog";
import { EditServiceDialog } from "../dialogs/EditServiceDialog";
import { useState } from "react";
import { use, useState } from "react";
import { PermissionsContext } from "/providers/PermissionsContext";
export const vendorServicesFragment = graphql`
fragment VendorServicesTabFragment on Vendor
@@ -94,6 +95,11 @@ export default function VendorServicesTab() {
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;
usePageTitle(vendor.name + " - " + __("Services"));
@@ -103,7 +109,7 @@ export default function VendorServicesTab() {
title={__("Services")}
description={__("Manage services provided by this vendor.")}
>
{!isSnapshotMode && (
{!isSnapshotMode && canCreateService && (
<CreateServiceDialog
vendorId={vendor.id}
connectionId={connectionId}
@@ -118,7 +124,7 @@ export default function VendorServicesTab() {
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Description")}</Th>
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -129,12 +135,14 @@ export default function VendorServicesTab() {
connectionId={connectionId}
onEdit={setEditingService}
isSnapshotMode={isSnapshotMode}
canUpdate={canUpdateService}
canDelete={canDeleteService}
/>
))}
</Tbody>
</SortableTable>
{editingService && !isSnapshotMode && (
{editingService && !isSnapshotMode && canUpdateService && (
<EditServiceDialog
serviceId={editingService.id}
service={editingService}
@@ -154,6 +162,8 @@ type ServiceRowProps = {
description?: string | null;
}) => void;
isSnapshotMode: boolean;
canUpdate?: boolean;
canDelete?: boolean;
};
function ServiceRow(props: ServiceRowProps) {
@@ -167,6 +177,7 @@ function ServiceRow(props: ServiceRowProps) {
successMessage: __("Service deleted successfully"),
errorMessage: __("Failed to delete service"),
});
const hasAnyAction = props.canUpdate || props.canDelete;
const handleDelete = () => {
confirm(
@@ -194,26 +205,30 @@ function ServiceRow(props: ServiceRowProps) {
<Tr>
<Td>{service.name}</Td>
<Td>{service.description || __("—")}</Td>
{!props.isSnapshotMode && (
{!props.isSnapshotMode && hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit({
id: service.id,
name: service.name,
description: service.description,
})}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
{props.canUpdate && (
<DropdownItem
icon={IconPencil}
onClick={() => props.onEdit({
id: service.id,
name: service.name,
description: service.description,
})}
>
{__("Edit")}
</DropdownItem>
)}
{props.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}