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

@@ -55,6 +55,7 @@ type Props<Params> = {
onAttach: Mutation<Params>;
onDetach: Mutation<Params>;
variant?: "card" | "table";
readOnly?: boolean;
};
export function LinkedAuditsCard<Params>(props: Props<Params>) {
@@ -97,16 +98,18 @@ export function LinkedAuditsCard<Params>(props: Props<Params>) {
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Audits")}</div>
<LinkedAuditsDialog
disabled={props.disabled}
linkedAudits={props.audits}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link audit")}
</Button>
</LinkedAuditsDialog>
{!props.readOnly && (
<LinkedAuditsDialog
disabled={props.disabled}
linkedAudits={props.audits}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link audit")}
</Button>
</LinkedAuditsDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
@@ -114,13 +117,13 @@ export function LinkedAuditsCard<Params>(props: Props<Params>) {
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{audits.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
<Td colSpan={props.readOnly ? 2 : 3} className="text-center text-txt-secondary">
{__("No audits linked")}
</Td>
</Tr>
@@ -130,9 +133,10 @@ export function LinkedAuditsCard<Params>(props: Props<Params>) {
key={audit.id}
audit={audit}
onClick={onDetach}
readOnly={props.readOnly}
/>
))}
{variant === "table" && (
{variant === "table" && !props.readOnly && (
<LinkedAuditsDialog
disabled={props.disabled}
linkedAudits={props.audits}
@@ -163,6 +167,7 @@ export function LinkedAuditsCard<Params>(props: Props<Params>) {
function AuditRow(props: {
audit: LinkedAuditsCardFragment$key & { id: string };
onClick: (auditId: string) => void;
readOnly?: boolean;
}) {
const audit = useFragment(linkedAuditFragment, props.audit);
const organizationId = useOrganizationId();
@@ -187,15 +192,17 @@ function AuditRow(props: {
{audit.state.replace(/_/g, " ")}
</Badge>
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(audit.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(audit.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -54,6 +54,7 @@ type Props<Params> = {
onAttach?: Mutation<Params>;
// Allow sorting in the table
refetch: ComponentProps<typeof SortableTable>["refetch"];
readOnly?: boolean;
};
/**
@@ -96,13 +97,13 @@ export function LinkedControlsCard<Params>(props: Props<Params>) {
<Tr>
<SortableTh field="SECTION_TITLE">{__("Reference")}</SortableTh>
<Th>{__("Name")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{controls.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
<Td colSpan={props.readOnly ? 2 : 3} className="text-center text-txt-secondary">
{__("No controls linked")}
</Td>
</Tr>
@@ -113,17 +114,20 @@ export function LinkedControlsCard<Params>(props: Props<Params>) {
control={control}
onClick={onDetach}
onAttach={onAttach}
readOnly={props.readOnly}
/>
))}
<LinkedControlsDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedControls={controls}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3}>{__("Link control")}</TrButton>
</LinkedControlsDialog>
{!props.readOnly && (
<LinkedControlsDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedControls={controls}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3}>{__("Link control")}</TrButton>
</LinkedControlsDialog>
)}
</Tbody>
</SortableTable>
);
@@ -133,6 +137,7 @@ function ControlRow(props: {
control: LinkedControlsCardFragment$key & { id: string };
onClick: (controlId: string) => void;
onAttach?: (controlId: string) => void;
readOnly?: boolean;
}) {
const control = useFragment(linkedControlFragment, props.control);
const organizationId = useOrganizationId();
@@ -149,15 +154,17 @@ function ControlRow(props: {
</span>
</Td>
<Td>{control.name}</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(control.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(control.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -64,6 +64,7 @@ type Props<Params> = {
// Mutation to detach a document (will receive {documentId, ...params})
onDetach: Mutation<Params>;
variant?: "card" | "table";
readOnly?: boolean;
};
/**
@@ -109,17 +110,19 @@ export function LinkedDocumentsCard<Params>(props: Props<Params>) {
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Documents")}</div>
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link document")}
</Button>
</LinkedDocumentDialog>
{!props.readOnly && (
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedDocuments={props.documents}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link document")}
</Button>
</LinkedDocumentDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
@@ -128,13 +131,13 @@ export function LinkedDocumentsCard<Params>(props: Props<Params>) {
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{documents.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
<Td colSpan={props.readOnly ? 3 : 4} className="text-center text-txt-secondary">
{__("No documents linked")}
</Td>
</Tr>
@@ -144,9 +147,10 @@ export function LinkedDocumentsCard<Params>(props: Props<Params>) {
key={document.id}
document={document}
onClick={onDetach}
readOnly={props.readOnly}
/>
))}
{variant === "table" && (
{variant === "table" && !props.readOnly && (
<LinkedDocumentDialog
connectionId={props.connectionId}
disabled={props.disabled}
@@ -178,6 +182,7 @@ export function LinkedDocumentsCard<Params>(props: Props<Params>) {
function DocumentRow(props: {
document: LinkedDocumentsCardFragment$key & { id: string };
onClick: (documentId: string) => void;
readOnly?: boolean;
}) {
const document = useFragment(linkedDocumentFragment, props.document);
const organizationId = useOrganizationId();
@@ -203,15 +208,17 @@ function DocumentRow(props: {
<Td>
<DocumentVersionBadge state={document.versions.edges[0].node.status} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(document.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(document.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -55,6 +55,7 @@ type Props<Params> = {
// Mutation to detach a measure (will receive {measureId, ...params})
onDetach: Mutation<Params>;
variant?: "card" | "table";
readOnly?: boolean;
};
/**
@@ -102,17 +103,19 @@ export function LinkedMeasuresCard<Params>(props: Props<Params>) {
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Measures")}</div>
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link measure")}
</Button>
</LinkedMeasureDialog>
{!props.readOnly && (
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedMeasures={props.measures}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link measure")}
</Button>
</LinkedMeasureDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
@@ -120,21 +123,21 @@ export function LinkedMeasuresCard<Params>(props: Props<Params>) {
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{measures.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
<Td colSpan={props.readOnly ? 2 : 3} className="text-center text-txt-secondary">
{__("No measures linked")}
</Td>
</Tr>
)}
{measures.map((measure) => (
<MeasureRow key={measure.id} measure={measure} onClick={onDetach} />
<MeasureRow key={measure.id} measure={measure} onClick={onDetach} readOnly={props.readOnly} />
))}
{variant === "table" && (
{variant === "table" && !props.readOnly && (
<LinkedMeasureDialog
connectionId={props.connectionId}
disabled={props.disabled}
@@ -166,6 +169,7 @@ export function LinkedMeasuresCard<Params>(props: Props<Params>) {
function MeasureRow(props: {
measure: LinkedMeasuresCardFragment$key & { id: string };
onClick: (measureId: string) => void;
readOnly?: boolean;
}) {
const measure = useFragment(linkedMeasureFragment, props.measure);
const organizationId = useOrganizationId();
@@ -177,15 +181,17 @@ function MeasureRow(props: {
<Td>
<MeasureBadge state={measure.state} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(measure.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(measure.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -51,6 +51,7 @@ type Props<Params> = {
connectionId: string;
disabled?: boolean;
variant?: "card" | "table";
readOnly?: boolean;
params: Params;
@@ -102,17 +103,19 @@ export function LinkedObligationsCard<Params>(props: Props<Params>) {
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Obligations")}</div>
<LinkedObligationDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedObligations={props.obligations}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link obligation")}
</Button>
</LinkedObligationDialog>
{!props.readOnly && (
<LinkedObligationDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedObligations={props.obligations}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link obligation")}
</Button>
</LinkedObligationDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
@@ -122,21 +125,21 @@ export function LinkedObligationsCard<Params>(props: Props<Params>) {
<Th>{__("Source")}</Th>
<Th>{__("Status")}</Th>
<Th>{__("Owner")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{obligations.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
<Td colSpan={props.readOnly ? 4 : 5} className="text-center text-txt-secondary">
{__("No obligations linked")}
</Td>
</Tr>
)}
{obligations.map((obligation) => (
<ObligationRow key={obligation.id} obligation={obligation} onClick={onDetach} />
<ObligationRow key={obligation.id} obligation={obligation} onClick={onDetach} readOnly={props.readOnly} />
))}
{variant === "table" && (
{variant === "table" && !props.readOnly && (
<LinkedObligationDialog
connectionId={props.connectionId}
disabled={props.disabled}
@@ -168,6 +171,7 @@ export function LinkedObligationsCard<Params>(props: Props<Params>) {
function ObligationRow(props: {
obligation: LinkedObligationsCardFragment$key & { id: string };
onClick: (obligationId: string) => void;
readOnly?: boolean;
}) {
const { __ } = useTranslate();
const obligation = useFragment(linkedObligationFragment, props.obligation);
@@ -199,15 +203,17 @@ function ObligationRow(props: {
<Td>
{obligation.owner?.fullName || __("Unassigned")}
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
icon={IconTrashCan}
onClick={onDetach}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
icon={IconTrashCan}
onClick={onDetach}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -45,7 +45,7 @@ const inviteMutation = graphql`
const schema = z.object({
email: z.string().email(),
fullName: z.string(),
role: z.enum(["OWNER", "ADMIN", "FULL", "VIEWER", "EMPLOYEE"]).default("VIEWER"),
role: z.enum(["OWNER", "ADMIN", "FULL", "VIEWER", "AUDITOR", "EMPLOYEE"]).default("VIEWER"),
createPeople: z.boolean().default(false),
});
@@ -126,6 +126,7 @@ function InviteUserDialogContent({ children, connectionId, onRefetch }: Props) {
{assignableRoles.includes("OWNER") && <Option value="OWNER">{__("Owner")}</Option>}
{assignableRoles.includes("ADMIN") && <Option value="ADMIN">{__("Admin")}</Option>}
{assignableRoles.includes("VIEWER") && <Option value="VIEWER">{__("Viewer")}</Option>}
{assignableRoles.includes("AUDITOR") && <Option value="AUDITOR">{__("Auditor")}</Option>}
{assignableRoles.includes("EMPLOYEE") && <Option value="EMPLOYEE">{__("Employee")}</Option>}
</Select>
<div className="mt-2 text-sm text-txt-tertiary">
@@ -138,6 +139,9 @@ function InviteUserDialogContent({ children, connectionId, onRefetch }: Props) {
{field.value === "VIEWER" && (
<p>{__("Read-only access")}</p>
)}
{field.value === "AUDITOR" && (
<p>{__("Read-only access without settings, tasks and meetings")}</p>
)}
{field.value === "EMPLOYEE" && (
<p>{__("Access to employee page")}</p>
)}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<4dda94f89f726842762d68c28d8180b6>>
* @generated SignedSource<<31b0b8ac2c59ccf3addd4508d56bd166>>
* @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 InviteUserInput = {
createPeople: boolean;
email: string;

View File

@@ -48,6 +48,7 @@ type Props<Params> = {
onAttach: Mutation<Params>;
// Mutation to detach a risk (will receive {riskId, ...params})
onDetach: Mutation<Params>;
readOnly?: boolean;
};
/**
@@ -88,29 +89,31 @@ export function LinkedRisksCard<Params>(props: Props<Params>) {
<Th>{__("Name")}</Th>
<Th>{__("Inherent Risk")}</Th>
<Th>{__("Residual Risk")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{props.risks.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
<Td colSpan={props.readOnly ? 3 : 4} className="text-center text-txt-secondary">
{__("No risks linked")}
</Td>
</Tr>
)}
{props.risks.map((risk) => (
<RiskRow key={risk.id} risk={risk} onClick={onDetach} />
<RiskRow key={risk.id} risk={risk} onClick={onDetach} readOnly={props.readOnly} />
))}
<LinkedRisksDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedRisks={props.risks}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={4}>{__("Link risk")}</TrButton>
</LinkedRisksDialog>
{!props.readOnly && (
<LinkedRisksDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedRisks={props.risks}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={4}>{__("Link risk")}</TrButton>
</LinkedRisksDialog>
)}
</Tbody>
</Table>
</div>
@@ -120,6 +123,7 @@ export function LinkedRisksCard<Params>(props: Props<Params>) {
function RiskRow(props: {
risk: LinkedRisksCardFragment$key & { id: string };
onClick: (riskId: string) => void;
readOnly?: boolean;
}) {
const risk = useFragment(linkedRiskFragment, props.risk);
const organizationId = useOrganizationId();
@@ -134,15 +138,17 @@ function RiskRow(props: {
<Td>
<RiskBadge level={risk.residualRiskScore} />
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(risk.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(risk.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -50,6 +50,7 @@ type Props<Params> = {
onAttach: Mutation<Params>;
onDetach: Mutation<Params>;
variant?: "card" | "table";
readOnly?: boolean;
};
export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
@@ -87,21 +88,26 @@ export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
const Wrapper = variant === "card" ? Card : "div";
const colSpanTable = props.readOnly ? 4 : 5;
const colSpanCard = props.readOnly ? 3 : 4;
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Snapshots")}</div>
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link snapshot")}
</Button>
</LinkedSnapshotsDialog>
{!props.readOnly && (
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link snapshot")}
</Button>
</LinkedSnapshotsDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
@@ -111,13 +117,13 @@ export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
<Th>{__("Type")}</Th>
{variant === "table" && <Th>{__("Description")}</Th>}
<Th>{__("Created")}</Th>
<Th></Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{snapshots.length === 0 && (
<Tr>
<Td colSpan={variant === "table" ? 5 : 3} className="text-center text-txt-secondary">
<Td colSpan={variant === "table" ? colSpanTable : colSpanCard} className="text-center text-txt-secondary">
{__("No snapshots linked")}
</Td>
</Tr>
@@ -128,16 +134,17 @@ export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
snapshot={snapshot}
onClick={onDetach}
variant={variant}
readOnly={props.readOnly}
/>
))}
{variant === "table" && (
{variant === "table" && !props.readOnly && (
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={variant === "table" ? 5 : 3} icon={IconPlusLarge}>
<TrButton colspan={colSpanTable} icon={IconPlusLarge}>
{__("Link snapshot")}
</TrButton>
</LinkedSnapshotsDialog>
@@ -162,6 +169,7 @@ function SnapshotRow(props: {
snapshot: LinkedSnapshotsCardFragment$key & { id: string };
onClick: (snapshotId: string) => void;
variant: "card" | "table";
readOnly?: boolean;
}) {
const snapshot = useFragment(linkedSnapshotFragment, props.snapshot);
const organizationId = useOrganizationId();
@@ -186,15 +194,17 @@ function SnapshotRow(props: {
<Td className="text-txt-tertiary">
{formatDate(snapshot.createdAt)}
</Td>
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(snapshot.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(snapshot.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -52,14 +52,10 @@ export const measureNodeQuery = graphql`
risksInfos: risks(first: 0) {
totalCount
}
tasksInfos: tasks(first: 0) {
totalCount
}
controlsInfos: controls(first: 0) {
totalCount
}
...MeasureRisksTabFragment
...MeasureTasksTabFragment
...MeasureControlsTabFragment
...MeasureFormDialogMeasureFragment
...MeasureEvidencesTabFragment

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<02c0b1d637edafea060ddb9ee3b3f130>>
* @generated SignedSource<<ba6c0f7696cba9d0f9e6e092f6c0f263>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -30,10 +30,7 @@ export type MeasureGraphNodeQuery$data = {
readonly totalCount: number;
};
readonly state?: MeasureState;
readonly tasksInfos?: {
readonly totalCount: number;
};
readonly " $fragmentSpreads": FragmentRefs<"MeasureControlsTabFragment" | "MeasureEvidencesTabFragment" | "MeasureFormDialogMeasureFragment" | "MeasureRisksTabFragment" | "MeasureTasksTabFragment">;
readonly " $fragmentSpreads": FragmentRefs<"MeasureControlsTabFragment" | "MeasureEvidencesTabFragment" | "MeasureFormDialogMeasureFragment" | "MeasureRisksTabFragment">;
};
};
export type MeasureGraphNodeQuery = {
@@ -128,16 +125,6 @@ v10 = {
"storageKey": "risks(first:0)"
},
v11 = {
"alias": "tasksInfos",
"args": (v7/*: any*/),
"concreteType": "TaskConnection",
"kind": "LinkedField",
"name": "tasks",
"plural": false,
"selections": (v8/*: any*/),
"storageKey": "tasks(first:0)"
},
v12 = {
"alias": "controlsInfos",
"args": (v7/*: any*/),
"concreteType": "ControlConnection",
@@ -147,55 +134,42 @@ v12 = {
"selections": (v8/*: any*/),
"storageKey": "controls(first:0)"
},
v13 = {
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v14 = [
v13 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
],
v15 = {
v14 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v16 = {
v15 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
v17 = {
v16 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
v18 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
(v16/*: any*/),
(v17/*: any*/)
],
"storageKey": null
},
v19 = {
v17 = {
"kind": "ClientExtension",
"selections": [
{
@@ -207,14 +181,14 @@ v19 = {
}
]
},
v20 = [
v18 = [
{
"kind": "Literal",
"name": "first",
"value": 20
}
],
v21 = {
v19 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
@@ -222,8 +196,8 @@ v21 = {
"name": "pageInfo",
"plural": false,
"selections": [
(v15/*: any*/),
(v16/*: any*/),
(v17/*: any*/),
{
"alias": null,
"args": null,
@@ -241,7 +215,7 @@ v21 = {
],
"storageKey": null
},
v22 = [
v20 = [
{
"kind": "Literal",
"name": "first",
@@ -274,17 +248,11 @@ return {
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "MeasureRisksTabFragment"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "MeasureTasksTabFragment"
},
{
"args": null,
"kind": "FragmentSpread",
@@ -325,7 +293,7 @@ return {
"name": "node",
"plural": false,
"selections": [
(v13/*: any*/),
(v12/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
@@ -337,10 +305,9 @@ return {
(v9/*: any*/),
(v10/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"alias": null,
"args": (v14/*: any*/),
"args": (v13/*: any*/),
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "risks",
@@ -378,22 +345,34 @@ return {
"name": "residualRiskScore",
"storageKey": null
},
(v13/*: any*/)
(v12/*: any*/)
],
"storageKey": null
},
(v15/*: any*/)
(v14/*: any*/)
],
"storageKey": null
},
(v18/*: any*/),
(v19/*: any*/)
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
(v15/*: any*/),
(v16/*: any*/)
],
"storageKey": null
},
(v17/*: any*/)
],
"storageKey": "risks(first:100)"
},
{
"alias": null,
"args": (v14/*: any*/),
"args": (v13/*: any*/),
"filters": null,
"handle": "connection",
"key": "Measure__risks",
@@ -402,102 +381,7 @@ return {
},
{
"alias": null,
"args": (v14/*: 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*/),
(v5/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "timeEstimate",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deadline",
"storageKey": null
},
{
"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
},
{
"alias": null,
"args": null,
"concreteType": "Measure",
"kind": "LinkedField",
"name": "measure",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
},
(v13/*: any*/)
],
"storageKey": null
},
(v15/*: any*/)
],
"storageKey": null
},
(v18/*: any*/),
(v19/*: any*/)
],
"storageKey": "tasks(first:100)"
},
{
"alias": null,
"args": (v14/*: any*/),
"filters": null,
"handle": "connection",
"key": "Measure__tasks",
"kind": "LinkedHandle",
"name": "tasks"
},
{
"alias": null,
"args": (v20/*: any*/),
"args": (v18/*: any*/),
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "controls",
@@ -541,22 +425,22 @@ return {
],
"storageKey": null
},
(v13/*: any*/)
(v12/*: any*/)
],
"storageKey": null
},
(v15/*: any*/)
(v14/*: any*/)
],
"storageKey": null
},
(v21/*: any*/),
(v19/*: any*/)
(v19/*: any*/),
(v17/*: any*/)
],
"storageKey": "controls(first:20)"
},
{
"alias": null,
"args": (v20/*: any*/),
"args": (v18/*: any*/),
"filters": [
"orderBy",
"filter"
@@ -568,7 +452,7 @@ return {
},
{
"alias": null,
"args": (v22/*: any*/),
"args": (v20/*: any*/),
"concreteType": "EvidenceConnection",
"kind": "LinkedField",
"name": "evidences",
@@ -638,22 +522,22 @@ return {
"name": "createdAt",
"storageKey": null
},
(v13/*: any*/)
(v12/*: any*/)
],
"storageKey": null
},
(v15/*: any*/)
(v14/*: any*/)
],
"storageKey": null
},
(v21/*: any*/),
(v19/*: any*/)
(v19/*: any*/),
(v17/*: any*/)
],
"storageKey": "evidences(first:50)"
},
{
"alias": null,
"args": (v22/*: any*/),
"args": (v20/*: any*/),
"filters": [
"orderBy"
],
@@ -672,16 +556,16 @@ return {
]
},
"params": {
"cacheID": "d20815cee9ca8dec2e09fcefdd54a68a",
"cacheID": "fb510405c5934dbaf802c61c3ee8a547",
"id": null,
"metadata": {},
"name": "MeasureGraphNodeQuery",
"operationKind": "query",
"text": "query MeasureGraphNodeQuery(\n $measureId: ID!\n) {\n node(id: $measureId) {\n __typename\n ... on Measure {\n id\n name\n description\n state\n category\n evidencesInfos: evidences(first: 0) {\n totalCount\n }\n risksInfos: risks(first: 0) {\n totalCount\n }\n tasksInfos: tasks(first: 0) {\n totalCount\n }\n controlsInfos: controls(first: 0) {\n totalCount\n }\n ...MeasureRisksTabFragment\n ...MeasureTasksTabFragment\n ...MeasureControlsTabFragment\n ...MeasureFormDialogMeasureFragment\n ...MeasureEvidencesTabFragment\n }\n id\n }\n}\n\nfragment LinkedControlsCardFragment on Control {\n id\n name\n sectionTitle\n framework {\n id\n name\n }\n}\n\nfragment LinkedRisksCardFragment on Risk {\n id\n name\n inherentRiskScore\n residualRiskScore\n}\n\nfragment MeasureControlsTabFragment on Measure {\n id\n controls(first: 20) {\n edges {\n node {\n id\n ...LinkedControlsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment on Measure {\n id\n evidences(first: 50) {\n edges {\n node {\n id\n file {\n fileName\n mimeType\n size\n id\n }\n ...MeasureEvidencesTabFragment_evidence\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment_evidence on Evidence {\n id\n file {\n fileName\n mimeType\n size\n id\n }\n type\n createdAt\n}\n\nfragment MeasureFormDialogMeasureFragment on Measure {\n id\n description\n name\n category\n state\n}\n\nfragment MeasureRisksTabFragment on Measure {\n id\n risks(first: 100) {\n edges {\n node {\n id\n ...LinkedRisksCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment MeasureTasksTabFragment on Measure {\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\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"
"text": "query MeasureGraphNodeQuery(\n $measureId: ID!\n) {\n node(id: $measureId) {\n __typename\n ... on Measure {\n id\n name\n description\n state\n category\n evidencesInfos: evidences(first: 0) {\n totalCount\n }\n risksInfos: risks(first: 0) {\n totalCount\n }\n controlsInfos: controls(first: 0) {\n totalCount\n }\n ...MeasureRisksTabFragment\n ...MeasureControlsTabFragment\n ...MeasureFormDialogMeasureFragment\n ...MeasureEvidencesTabFragment\n }\n id\n }\n}\n\nfragment LinkedControlsCardFragment on Control {\n id\n name\n sectionTitle\n framework {\n id\n name\n }\n}\n\nfragment LinkedRisksCardFragment on Risk {\n id\n name\n inherentRiskScore\n residualRiskScore\n}\n\nfragment MeasureControlsTabFragment on Measure {\n id\n controls(first: 20) {\n edges {\n node {\n id\n ...LinkedControlsCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment on Measure {\n id\n evidences(first: 50) {\n edges {\n node {\n id\n file {\n fileName\n mimeType\n size\n id\n }\n ...MeasureEvidencesTabFragment_evidence\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeasureEvidencesTabFragment_evidence on Evidence {\n id\n file {\n fileName\n mimeType\n size\n id\n }\n type\n createdAt\n}\n\nfragment MeasureFormDialogMeasureFragment on Measure {\n id\n description\n name\n category\n state\n}\n\nfragment MeasureRisksTabFragment on Measure {\n id\n risks(first: 100) {\n edges {\n node {\n id\n ...LinkedRisksCardFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
}
};
})();
(node as any).hash = "34931b0c662961228537121bf18bab4e";
(node as any).hash = "fea0fb9de2bebb764995341906e0de58";
export default node;

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>
)}

View File

@@ -165,10 +165,14 @@ const routes = [
path: "",
Component: () => {
const { role } = use(PermissionsContext);
if (role === Role.EMPLOYEE) {
return <Navigate to="employee" />;
switch (role) {
case Role.EMPLOYEE:
return <Navigate to="employee" />;
case Role.AUDITOR:
return <Navigate to="measures" />;
default:
return <Navigate to="tasks" />;
}
return <Navigate to="tasks" />;
},
},
{

View File

@@ -2,6 +2,7 @@ export const Role = {
OWNER: "OWNER",
ADMIN: "ADMIN",
VIEWER: "VIEWER",
AUDITOR: "AUDITOR",
EMPLOYEE: "EMPLOYEE",
} as const
@@ -9,11 +10,11 @@ export type Role = (typeof Role)[keyof typeof Role];
export function getAssignableRoles(currentRole: Role): Role[] {
if (currentRole === Role.OWNER) {
return [Role.OWNER, Role.ADMIN, Role.VIEWER, Role.EMPLOYEE];
return [Role.OWNER, Role.ADMIN, Role.VIEWER, Role.AUDITOR, Role.EMPLOYEE];
}
if (currentRole === Role.ADMIN) {
return [Role.ADMIN, Role.VIEWER, Role.EMPLOYEE];
return [Role.ADMIN, Role.VIEWER, Role.AUDITOR, Role.EMPLOYEE];
}
return [];

View File

@@ -31,6 +31,7 @@ const (
RoleAdmin Role = "ADMIN"
RoleEmployee Role = "EMPLOYEE"
RoleViewer Role = "VIEWER"
RoleAuditor Role = "AUDITOR"
RoleFull Role = "FULL"
)
@@ -257,33 +258,32 @@ const (
)
var (
AllRoles = []Role{RoleOwner, RoleAdmin, RoleEmployee, RoleViewer, RoleFull}
NonEmployeeRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleFull}
EditRoles = []Role{RoleOwner, RoleAdmin, RoleFull}
AllRoles = []Role{RoleOwner, RoleAdmin, RoleEmployee, RoleViewer, RoleAuditor, RoleFull}
EditRoles = []Role{RoleOwner, RoleAdmin, RoleFull}
CoreRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleFull}
NonEmployeeRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleAuditor, RoleFull}
InternalRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleEmployee, RoleFull}
)
var Permissions = map[uint16]map[Action][]Role{
coredata.OrganizationEntityType: {
ActionGet: AllRoles,
ActionListSignableDocuments: AllRoles,
ActionGetLogoUrl: AllRoles,
ActionGet: AllRoles,
ActionGetLogoUrl: AllRoles,
ActionListSignableDocuments: InternalRoles,
ActionListDocuments: NonEmployeeRoles,
ActionGetHorizontalLogoUrl: NonEmployeeRoles,
ActionMemberships: NonEmployeeRoles,
ActionPeoples: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionListMembers: NonEmployeeRoles,
ActionListInvitations: NonEmployeeRoles,
ActionListSlackConnections: NonEmployeeRoles,
ActionListFrameworks: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionListPeople: NonEmployeeRoles,
ActionListMeetings: NonEmployeeRoles,
ActionListMeasures: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionListTasks: NonEmployeeRoles,
ActionListAssets: NonEmployeeRoles,
ActionListData: NonEmployeeRoles,
ActionListAudits: NonEmployeeRoles,
@@ -292,13 +292,20 @@ var Permissions = map[uint16]map[Action][]Role{
ActionListContinualImprovements: NonEmployeeRoles,
ActionListProcessingActivities: NonEmployeeRoles,
ActionListSnapshots: NonEmployeeRoles,
ActionListTrustCenterFiles: NonEmployeeRoles,
ActionGetTrustCenter: NonEmployeeRoles,
ActionGetCustomDomain: NonEmployeeRoles,
ActionListSAMLConfigurations: NonEmployeeRoles,
ActionConfirmEmail: NonEmployeeRoles,
ActionAcceptInvitation: NonEmployeeRoles,
ActionListTrustCenterFiles: CoreRoles,
ActionGetTrustCenter: CoreRoles,
ActionMemberships: CoreRoles,
ActionListMembers: CoreRoles,
ActionListInvitations: CoreRoles,
ActionListSlackConnections: CoreRoles,
ActionGetCustomDomain: CoreRoles,
ActionListSAMLConfigurations: CoreRoles,
ActionListMeetings: CoreRoles,
ActionListTasks: CoreRoles,
ActionUpdateOrganization: EditRoles,
ActionDeleteOrganizationHorizontalLogo: EditRoles,
ActionCreateTrustCenter: EditRoles,
@@ -336,11 +343,11 @@ var Permissions = map[uint16]map[Action][]Role{
ActionDeleteOrganization: {RoleOwner},
},
coredata.TrustCenterEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetNdaFileUrl: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListAccesses: NonEmployeeRoles,
ActionListReferences: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionGetNdaFileUrl: CoreRoles,
ActionGetOrganization: CoreRoles,
ActionListAccesses: CoreRoles,
ActionListReferences: CoreRoles,
ActionUpdateTrustCenter: EditRoles,
ActionUploadTrustCenterNDA: EditRoles,
@@ -349,26 +356,26 @@ var Permissions = map[uint16]map[Action][]Role{
ActionCreateTrustCenterReference: EditRoles,
},
coredata.TrustCenterAccessEntityType: {
ActionGet: NonEmployeeRoles,
ActionActiveCount: NonEmployeeRoles,
ActionPendingRequestCount: NonEmployeeRoles,
ActionAvailableDocumentAccesses: NonEmployeeRoles,
ActionGetTrustCenterFile: NonEmployeeRoles,
ActionGetReport: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionActiveCount: CoreRoles,
ActionPendingRequestCount: CoreRoles,
ActionAvailableDocumentAccesses: CoreRoles,
ActionGetTrustCenterFile: CoreRoles,
ActionGetReport: CoreRoles,
ActionUpdateTrustCenterAccess: EditRoles,
ActionDeleteTrustCenterAccess: EditRoles,
},
coredata.TrustCenterReferenceEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetLogoUrl: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionGetLogoUrl: CoreRoles,
ActionUpdateTrustCenterReference: EditRoles,
ActionDeleteTrustCenterReference: EditRoles,
},
coredata.TrustCenterFileEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetFileUrl: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionGetFileUrl: CoreRoles,
ActionUpdateTrustCenterFile: EditRoles,
ActionGetTrustCenterFile: EditRoles,
@@ -487,9 +494,10 @@ var Permissions = map[uint16]map[Action][]Role{
ActionDeleteControlSnapshotMapping: EditRoles,
},
coredata.MeasureEntityType: {
ActionListTasks: CoreRoles,
ActionGet: NonEmployeeRoles,
ActionListEvidences: NonEmployeeRoles,
ActionListTasks: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
@@ -499,11 +507,11 @@ var Permissions = map[uint16]map[Action][]Role{
ActionUploadMeasureEvidence: EditRoles,
},
coredata.TaskEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetAssignedTo: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionGetMeasure: NonEmployeeRoles,
ActionListEvidences: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionGetAssignedTo: CoreRoles,
ActionGetOrganization: CoreRoles,
ActionGetMeasure: CoreRoles,
ActionListEvidences: CoreRoles,
ActionUpdateTask: EditRoles,
ActionDeleteTask: EditRoles,
@@ -513,7 +521,7 @@ var Permissions = map[uint16]map[Action][]Role{
coredata.EvidenceEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetFile: NonEmployeeRoles,
ActionGetTask: NonEmployeeRoles,
ActionGetTask: CoreRoles,
ActionGetMeasure: NonEmployeeRoles,
ActionDeleteEvidence: EditRoles,
@@ -521,7 +529,8 @@ var Permissions = map[uint16]map[Action][]Role{
coredata.DocumentEntityType: {
ActionListSignableDocumentVersion: AllRoles,
ActionGetSigned: AllRoles,
ActionGetSignableDocument: AllRoles,
ActionGetSignableDocument: InternalRoles,
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
@@ -689,14 +698,14 @@ var Permissions = map[uint16]map[Action][]Role{
ActionDownloadUrl: NonEmployeeRoles,
},
coredata.TrustCenterDocumentAccessEntityType: {
ActionGet: NonEmployeeRoles,
ActionReport: NonEmployeeRoles,
ActionTrustCenterFile: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionReport: CoreRoles,
ActionTrustCenterFile: CoreRoles,
},
coredata.MeetingEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionGet: CoreRoles,
ActionGetOrganization: CoreRoles,
ActionTotalCount: CoreRoles,
ActionUpdateMeeting: EditRoles,
ActionDeleteMeeting: EditRoles,

View File

@@ -220,7 +220,7 @@ var entityRegistry = map[uint16]EntityInfo{
Table: "trust_center_references",
},
TrustCenterDocumentAccessEntityType: {
Model: "",
Model: "TrustCenterDocumentAccess",
Table: "trust_center_document_accesses",
},
CustomDomainEntityType: {

View File

@@ -26,6 +26,7 @@ const (
MembershipRoleAdmin MembershipRole = "ADMIN"
MembershipRoleEmployee MembershipRole = "EMPLOYEE"
MembershipRoleViewer MembershipRole = "VIEWER"
MembershipRoleAuditor MembershipRole = "AUDITOR"
)
func (r MembershipRole) String() string {
@@ -52,6 +53,8 @@ func (r *MembershipRole) Scan(value any) error {
*r = MembershipRoleEmployee
case "VIEWER":
*r = MembershipRoleViewer
case "AUDITOR":
*r = MembershipRoleAuditor
default:
return fmt.Errorf("invalid MembershipRole value: %q", s)
}

View File

@@ -0,0 +1 @@
ALTER TYPE authz_role ADD VALUE 'AUDITOR';

View File

@@ -38,6 +38,7 @@ enum Role {
OWNER
ADMIN
VIEWER
AUDITOR
FULL
}
@@ -98,6 +99,7 @@ enum MembershipRole
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleEmployee")
VIEWER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleViewer")
AUDITOR @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAuditor")
}
enum APIRole @goModel(model: "go.probo.inc/probo/pkg/coredata.APIRole") {

View File

@@ -9592,6 +9592,7 @@ enum Role {
OWNER
ADMIN
VIEWER
AUDITOR
FULL
}
@@ -9652,6 +9653,7 @@ enum MembershipRole
EMPLOYEE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleEmployee")
VIEWER @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleViewer")
AUDITOR @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipRoleAuditor")
}
enum APIRole @goModel(model: "go.probo.inc/probo/pkg/coredata.APIRole") {
@@ -87915,12 +87917,14 @@ var (
"ADMIN": coredata.MembershipRoleAdmin,
"EMPLOYEE": coredata.MembershipRoleEmployee,
"VIEWER": coredata.MembershipRoleViewer,
"AUDITOR": coredata.MembershipRoleAuditor,
}
marshalNMembershipRole2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipRole = map[coredata.MembershipRole]string{
coredata.MembershipRoleOwner: "OWNER",
coredata.MembershipRoleAdmin: "ADMIN",
coredata.MembershipRoleEmployee: "EMPLOYEE",
coredata.MembershipRoleViewer: "VIEWER",
coredata.MembershipRoleAuditor: "AUDITOR",
}
)

View File

@@ -2537,22 +2537,24 @@ type Viewer struct {
type Role string
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleViewer Role = "VIEWER"
RoleFull Role = "FULL"
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleViewer Role = "VIEWER"
RoleAuditor Role = "AUDITOR"
RoleFull Role = "FULL"
)
var AllRole = []Role{
RoleOwner,
RoleAdmin,
RoleViewer,
RoleAuditor,
RoleFull,
}
func (e Role) IsValid() bool {
switch e {
case RoleOwner, RoleAdmin, RoleViewer, RoleFull:
case RoleOwner, RoleAdmin, RoleViewer, RoleAuditor, RoleFull:
return true
}
return false