Add role management
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -68,6 +68,20 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error && error.toString().includes("FORBIDDEN")) {
|
||||
return (
|
||||
<div className={classNames.wrapper}>
|
||||
<h1 className={classNames.title}>
|
||||
<IconPageCross size={26} />
|
||||
{__("Page not found")}
|
||||
</h1>
|
||||
<p className={classNames.description}>
|
||||
{__("The page you are looking for does not exist")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && error.toString().includes("UNAUTHORIZED")) {
|
||||
return (
|
||||
<div className={classNames.wrapper}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CustomDomainManagerDeleteMutation } from "./__generated__/CustomDo
|
||||
import { CreateCustomDomainDialog } from "./CreateCustomDomainDialog";
|
||||
import { DeleteCustomDomainDialog } from "./DeleteCustomDomainDialog";
|
||||
import { DomainDetailsDialog } from "./DomainDetailsDialog";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const deleteCustomDomainMutation = graphql`
|
||||
mutation CustomDomainManagerDeleteMutation($input: DeleteCustomDomainInput!) {
|
||||
@@ -106,9 +107,11 @@ export function CustomDomainManager({
|
||||
)}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<CreateCustomDomainDialog organizationId={organizationId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add Domain")}</Button>
|
||||
</CreateCustomDomainDialog>
|
||||
<Authorized entity="Organization" action="createCustomDomain">
|
||||
<CreateCustomDomainDialog organizationId={organizationId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add Domain")}</Button>
|
||||
</CreateCustomDomainDialog>
|
||||
</Authorized>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -136,12 +139,14 @@ export function CustomDomainManager({
|
||||
<Button variant="secondary">{__("View Details")}</Button>
|
||||
</DomainDetailsDialog>
|
||||
|
||||
<DeleteCustomDomainDialog
|
||||
domainName={domain.domain}
|
||||
onConfirm={handleDeleteDomain}
|
||||
>
|
||||
<Button variant="danger">{__("Delete")}</Button>
|
||||
</DeleteCustomDomainDialog>
|
||||
<Authorized entity="CustomDomain" action="deleteCustomDomain">
|
||||
<DeleteCustomDomainDialog
|
||||
domainName={domain.domain}
|
||||
onConfirm={handleDeleteDomain}
|
||||
>
|
||||
<Button variant="danger">{__("Delete")}</Button>
|
||||
</DeleteCustomDomainDialog>
|
||||
</Authorized>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
Checkbox,
|
||||
Select,
|
||||
Option,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import type { PropsWithChildren } from "react";
|
||||
@@ -15,6 +17,8 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { Suspense } from "react";
|
||||
import { getAssignableRoles } from "/permissions";
|
||||
|
||||
const inviteMutation = graphql`
|
||||
mutation InviteUserDialogMutation(
|
||||
@@ -40,6 +44,7 @@ const inviteMutation = graphql`
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
fullName: z.string(),
|
||||
role: z.enum(["OWNER", "ADMIN", "FULL", "VIEWER"]).default("VIEWER"),
|
||||
createPeople: z.boolean().default(false),
|
||||
});
|
||||
|
||||
@@ -48,16 +53,17 @@ type Props = PropsWithChildren & {
|
||||
onRefetch: () => void;
|
||||
};
|
||||
|
||||
export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
|
||||
function InviteUserDialogContent({ children, connectionId, onRefetch }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const assignableRoles = getAssignableRoles(organizationId);
|
||||
const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, {
|
||||
successMessage: __("Invitation sent successfully"),
|
||||
errorMessage: __("Failed to send invitation"),
|
||||
});
|
||||
const { register, handleSubmit, formState, reset, control } = useFormWithSchema(
|
||||
schema,
|
||||
{ defaultValues: { createPeople: false } },
|
||||
{ defaultValues: { role: "VIEWER", createPeople: false } },
|
||||
);
|
||||
|
||||
const dialogRef = useDialogRef();
|
||||
@@ -69,6 +75,7 @@ export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
|
||||
organizationId,
|
||||
email: data.email,
|
||||
fullName: data.fullName,
|
||||
role: data.role,
|
||||
createPeople: data.createPeople,
|
||||
},
|
||||
connections: connectionId ? [connectionId] : ["SettingsPageInvitations_invitations"],
|
||||
@@ -107,6 +114,32 @@ export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
|
||||
{...register("fullName")}
|
||||
error={formState.errors.fullName?.message}
|
||||
/>
|
||||
<Field label={__("Role")} required>
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
{assignableRoles.includes("OWNER") && <Option value="OWNER">{__("Owner")}</Option>}
|
||||
{assignableRoles.includes("ADMIN") && <Option value="ADMIN">{__("Admin")}</Option>}
|
||||
{assignableRoles.includes("VIEWER") && <Option value="VIEWER">{__("Viewer")}</Option>}
|
||||
</Select>
|
||||
<div className="mt-2 text-sm text-txt-tertiary">
|
||||
{field.value === "OWNER" && (
|
||||
<p>{__("Full access to everything")}</p>
|
||||
)}
|
||||
{field.value === "ADMIN" && (
|
||||
<p>{__("Full access except organization setup and API keys")}</p>
|
||||
)}
|
||||
{field.value === "VIEWER" && (
|
||||
<p>{__("Read-only access")}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Controller
|
||||
@@ -142,3 +175,11 @@ export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteUserDialog(props: Props) {
|
||||
return (
|
||||
<Suspense fallback={props.children}>
|
||||
<InviteUserDialogContent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Badge, Button, Card } from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import type { TrustCenterGraphQuery$data } from "/hooks/graph/__generated__/TrustCenterGraphQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
organizationId: string;
|
||||
@@ -76,9 +77,11 @@ export function SlackConnections({ organizationId, slackConnections: connectedSl
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="secondary" asChild>
|
||||
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
|
||||
</Button>
|
||||
<Authorized entity="TrustCenter" action="updateTrustCenter">
|
||||
<Button variant="secondary" asChild>
|
||||
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<617ea2708c8402c706671dc1a63b1316>>
|
||||
* @generated SignedSource<<eda42f72473c65692ddd9cee68c0ce81>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -9,12 +9,13 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type Role = "ADMIN" | "MEMBER" | "OWNER" | "VIEWER";
|
||||
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
|
||||
export type InviteUserInput = {
|
||||
createPeople: boolean;
|
||||
email: string;
|
||||
fullName: string;
|
||||
organizationId: string;
|
||||
role: MembershipRole;
|
||||
};
|
||||
export type InviteUserDialogMutation$variables = {
|
||||
connections: ReadonlyArray<string>;
|
||||
@@ -30,7 +31,7 @@ export type InviteUserDialogMutation$data = {
|
||||
readonly expiresAt: any;
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
readonly role: Role;
|
||||
readonly role: MembershipRole;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ import TaskFormDialog, {
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { Link, useLocation, useParams } from "react-router";
|
||||
import { promisifyMutation } from "@probo/helpers";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
import type { TaskFormDialogFragment$key } from "./__generated__/TaskFormDialogFragment.graphql";
|
||||
import { updateStoreCounter } from "/hooks/useMutationWithIncrement";
|
||||
|
||||
@@ -49,6 +51,7 @@ type Props = {
|
||||
|
||||
export default function TasksCard({ tasks, connectionId }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const hash = useLocation().hash.replace("#", "");
|
||||
|
||||
const hashes = [
|
||||
@@ -67,6 +70,9 @@ export default function TasksCard({ tasks, connectionId }: Props) {
|
||||
|
||||
usePageTitle(__("Tasks"));
|
||||
|
||||
const hasAnyAction = isAuthorized(organizationId, "Task", "updateTask") ||
|
||||
isAuthorized(organizationId, "Task", "deleteTask");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{tasks?.length === 0 ? (
|
||||
@@ -100,6 +106,7 @@ export default function TasksCard({ tasks, connectionId }: Props) {
|
||||
key={task.id}
|
||||
task={task}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
@@ -110,6 +117,7 @@ export default function TasksCard({ tasks, connectionId }: Props) {
|
||||
key={task.id}
|
||||
task={task}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -122,6 +130,7 @@ export default function TasksCard({ tasks, connectionId }: Props) {
|
||||
type TaskRowProps = {
|
||||
task: ItemOf<Props["tasks"]> & TaskFormDialogFragment$key;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
const deleteMutation = graphql`
|
||||
@@ -221,21 +230,27 @@ function TaskRow(props: TaskRowProps) {
|
||||
<Avatar name={props.task.assignedTo?.fullName ?? ""} />
|
||||
</Link>
|
||||
)}
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
{props.hasAnyAction && (
|
||||
<ActionDropdown>
|
||||
<Authorized entity="Task" action="updateTask">
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
<Authorized entity="Task" action="deleteTask">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { sprintf, getAuditStateVariant, getAuditStateLabel, formatDate, getTrustCenterVisibilityOptions } from "@probo/helpers";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import type { TrustCenterAuditsCardFragment$key } from "./__generated__/TrustCenterAuditsCardFragment.graphql";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const trustCenterAuditFragment = graphql`
|
||||
fragment TrustCenterAuditsCardFragment on Audit {
|
||||
@@ -124,6 +125,8 @@ function AuditRow(props: {
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
|
||||
|
||||
const canUpdate = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
|
||||
|
||||
const handleValueChange = useCallback((value: string | {}) => {
|
||||
const stringValue = typeof value === 'string' ? value : '';
|
||||
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
|
||||
@@ -164,7 +167,7 @@ function AuditRow(props: {
|
||||
type="select"
|
||||
value={currentValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={props.disabled}
|
||||
disabled={props.disabled || !canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map((option) => (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useFragment } from "react-relay";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { sprintf, getTrustCenterVisibilityOptions } from "@probo/helpers";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const trustCenterDocumentFragment = graphql`
|
||||
fragment TrustCenterDocumentsCardFragment on Document {
|
||||
@@ -129,6 +130,8 @@ function DocumentRow(props: {
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
|
||||
|
||||
const canUpdate = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
|
||||
|
||||
const handleValueChange = useCallback((value: string | {}) => {
|
||||
const stringValue = typeof value === 'string' ? value : '';
|
||||
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
|
||||
@@ -164,7 +167,7 @@ function DocumentRow(props: {
|
||||
type="select"
|
||||
value={currentValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={props.disabled}
|
||||
disabled={props.disabled || !canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map((option) => (
|
||||
|
||||
@@ -21,6 +21,9 @@ import { useFragment } from "react-relay";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { sprintf, getTrustCenterVisibilityOptions } from "@probo/helpers";
|
||||
import { formatDate } from "@probo/helpers";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
const trustCenterFileFragment = graphql`
|
||||
fragment TrustCenterFilesCardFragment on TrustCenterFile {
|
||||
@@ -147,6 +150,9 @@ function FileRow(props: {
|
||||
const file = props.file;
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
|
||||
const { organizationId } = useParams();
|
||||
|
||||
const canUpdate = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
|
||||
|
||||
const handleValueChange = useCallback((value: string | {}) => {
|
||||
const stringValue = typeof value === 'string' ? value : '';
|
||||
@@ -179,7 +185,7 @@ function FileRow(props: {
|
||||
type="select"
|
||||
value={currentValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={props.disabled}
|
||||
disabled={props.disabled || !canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map((option) => (
|
||||
@@ -201,20 +207,24 @@ function FileRow(props: {
|
||||
onClick={() => window.open(file.fileUrl, '_blank', 'noopener,noreferrer')}
|
||||
title={__("Download")}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPencil}
|
||||
onClick={() => props.onEdit({ id: file.id, name: file.name, category: file.category })}
|
||||
disabled={props.disabled}
|
||||
title={__("Edit")}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => props.onDelete(file.id)}
|
||||
disabled={props.disabled}
|
||||
title={__("Delete")}
|
||||
/>
|
||||
<Authorized entity="TrustCenterFile" action="updateTrustCenterFile">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPencil}
|
||||
onClick={() => props.onEdit({ id: file.id, name: file.name, category: file.category })}
|
||||
disabled={props.disabled}
|
||||
title={__("Edit")}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="TrustCenterFile" action="deleteTrustCenterFile">
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => props.onDelete(file.id)}
|
||||
disabled={props.disabled}
|
||||
title={__("Delete")}
|
||||
/>
|
||||
</Authorized>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "/hooks/graph/TrustCenterReferenceGraph";
|
||||
import { TrustCenterReferenceDialog, type TrustCenterReferenceDialogRef } from "./TrustCenterReferenceDialog";
|
||||
import { DeleteTrustCenterReferenceDialog } from "./DeleteTrustCenterReferenceDialog";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
trustCenterId: string;
|
||||
@@ -111,12 +112,14 @@ export function TrustCenterReferencesSection({ trustCenterId }: Props) {
|
||||
{__("Showcase your customers and partners on your trust center")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon={IconPlusLarge}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{__("Add Reference")}
|
||||
</Button>
|
||||
<Authorized entity="TrustCenter" action="createTrustCenterReference">
|
||||
<Button
|
||||
icon={IconPlusLarge}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{__("Add Reference")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
@@ -228,21 +231,25 @@ function ReferenceRow({
|
||||
icon={IconArrowLink}
|
||||
onClick={onVisitWebsite}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPencil}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<DeleteTrustCenterReferenceDialog
|
||||
referenceId={reference.id}
|
||||
referenceName={reference.name}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Authorized entity="TrustCenterReference" action="updateTrustCenterReference">
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
variant="secondary"
|
||||
icon={IconPencil}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
</DeleteTrustCenterReferenceDialog>
|
||||
</Authorized>
|
||||
<Authorized entity="TrustCenterReference" action="deleteTrustCenterReference">
|
||||
<DeleteTrustCenterReferenceDialog
|
||||
referenceId={reference.id}
|
||||
referenceName={reference.name}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
/>
|
||||
</DeleteTrustCenterReferenceDialog>
|
||||
</Authorized>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useFragment } from "react-relay";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { isAuthorized } from "/permissions/permissions";
|
||||
import type { TrustCenterVendorsCardFragment$key } from "./__generated__/TrustCenterVendorsCardFragment.graphql";
|
||||
|
||||
const trustCenterVendorFragment = graphql`
|
||||
@@ -48,12 +49,44 @@ type Props<Params> = {
|
||||
|
||||
export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const [limit, setLimit] = useState<number | null>(100);
|
||||
const [canUpdate, setCanUpdate] = useState<boolean>(false);
|
||||
|
||||
const vendors = useMemo(() => {
|
||||
return limit ? props.vendors.slice(0, limit) : props.vendors;
|
||||
}, [props.vendors, limit]);
|
||||
const showMoreButton = limit !== null && props.vendors.length > limit;
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setCanUpdate(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authorized = isAuthorized(organizationId, "Vendor", "updateVendor");
|
||||
setCanUpdate(authorized);
|
||||
} catch (promise) {
|
||||
if (promise instanceof Promise) {
|
||||
promise
|
||||
.then(() => {
|
||||
try {
|
||||
const authorized = isAuthorized(organizationId, "Vendor", "updateVendor");
|
||||
setCanUpdate(authorized);
|
||||
} catch {
|
||||
setCanUpdate(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setCanUpdate(false);
|
||||
});
|
||||
} else {
|
||||
setCanUpdate(false);
|
||||
}
|
||||
}
|
||||
}, [organizationId]);
|
||||
|
||||
const onToggleVisibility = (vendorId: string, showOnTrustCenter: boolean) => {
|
||||
props.onToggleVisibility({
|
||||
variables: {
|
||||
@@ -74,13 +107,13 @@ export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Category")}</Th>
|
||||
<Th>{__("Visibility")}</Th>
|
||||
<Th></Th>
|
||||
{canUpdate && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{vendors.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="text-center text-txt-secondary">
|
||||
<Td colSpan={canUpdate ? 4 : 3} className="text-center text-txt-secondary">
|
||||
{__("No vendors available")}
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -91,6 +124,7 @@ export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
|
||||
vendor={vendor}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
disabled={props.disabled}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -113,6 +147,7 @@ function VendorRow(props: {
|
||||
vendor: TrustCenterVendorsCardFragment$key;
|
||||
onToggleVisibility: (vendorId: string, showOnTrustCenter: boolean) => void;
|
||||
disabled?: boolean;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
const vendor = useFragment(trustCenterVendorFragment, props.vendor);
|
||||
const organizationId = useOrganizationId();
|
||||
@@ -135,16 +170,18 @@ function VendorRow(props: {
|
||||
{vendor.showOnTrustCenter ? __("Visible") : __("None")}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td noLink width={100} className="text-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => props.onToggleVisibility(vendor.id, !vendor.showOnTrustCenter)}
|
||||
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{vendor.showOnTrustCenter ? __("Hide") : __("Show")}
|
||||
</Button>
|
||||
</Td>
|
||||
{props.canUpdate && (
|
||||
<Td noLink width={100} className="text-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => props.onToggleVisibility(vendor.id, !vendor.showOnTrustCenter)}
|
||||
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{vendor.showOnTrustCenter ? __("Hide") : __("Show")}
|
||||
</Button>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useToast } from "@probo/ui";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { PageError } from "/components/PageError";
|
||||
import { buildEndpoint } from "/providers/RelayProviders";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const MainLayoutQuery = graphql`
|
||||
query MainLayoutQuery($organizationId: ID!) {
|
||||
@@ -72,7 +73,6 @@ const MainLayoutQuery = graphql`
|
||||
*/
|
||||
export function MainLayout() {
|
||||
const { organizationId } = useParams();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const prefix = `/organizations/${organizationId}`;
|
||||
|
||||
@@ -80,14 +80,31 @@ export function MainLayout() {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="w-full h-screen" />}>
|
||||
<MainLayoutContent organizationId={organizationId} prefix={prefix} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MainLayoutContent({
|
||||
organizationId,
|
||||
prefix,
|
||||
}: {
|
||||
organizationId: string;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<div className="mr-auto">
|
||||
<Suspense fallback={<Skeleton className="w-20 h-8" />}>
|
||||
<OrganizationSelectorWrapper organizationId={organizationId} />
|
||||
</Suspense>
|
||||
<OrganizationSelector currentOrganization={data.organization} />
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
|
||||
<UserDropdown organizationId={organizationId} />
|
||||
@@ -96,96 +113,132 @@ export function MainLayout() {
|
||||
}
|
||||
sidebar={
|
||||
<ul className="space-y-[2px]">
|
||||
<SidebarItem
|
||||
label={__("Meetings")}
|
||||
icon={IconCalendar1}
|
||||
to={`${prefix}/meetings`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Tasks")}
|
||||
icon={IconInboxEmpty}
|
||||
to={`${prefix}/tasks`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Measures")}
|
||||
icon={IconTodo}
|
||||
to={`${prefix}/measures`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Risks")}
|
||||
icon={IconFire3}
|
||||
to={`${prefix}/risks`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Frameworks")}
|
||||
icon={IconBank}
|
||||
to={`${prefix}/frameworks`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("People")}
|
||||
icon={IconGroup1}
|
||||
to={`${prefix}/people`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Vendors")}
|
||||
icon={IconStore}
|
||||
to={`${prefix}/vendors`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Documents")}
|
||||
icon={IconPageTextLine}
|
||||
to={`${prefix}/documents`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Assets")}
|
||||
icon={IconBox}
|
||||
to={`${prefix}/assets`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Data")}
|
||||
icon={IconListStack}
|
||||
to={`${prefix}/data`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Audits")}
|
||||
icon={IconMedal}
|
||||
to={`${prefix}/audits`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Nonconformities")}
|
||||
icon={IconCrossLargeX}
|
||||
to={`${prefix}/nonconformities`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Obligations")}
|
||||
icon={IconBook}
|
||||
to={`${prefix}/obligations`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Continual Improvements")}
|
||||
icon={IconRotateCw}
|
||||
to={`${prefix}/continual-improvements`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Processing Activities")}
|
||||
icon={IconCircleProgress}
|
||||
to={`${prefix}/processing-activities`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Snapshots")}
|
||||
icon={IconClock}
|
||||
to={`${prefix}/snapshots`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Trust Center")}
|
||||
icon={IconShield}
|
||||
to={`${prefix}/trust-center`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Settings")}
|
||||
icon={IconSettingsGear2}
|
||||
to={`${prefix}/settings`}
|
||||
/>
|
||||
<Authorized entity="Organization" action="listMeetings">
|
||||
<SidebarItem
|
||||
label={__("Meetings")}
|
||||
icon={IconCalendar1}
|
||||
to={`${prefix}/meetings`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listTasks">
|
||||
<SidebarItem
|
||||
label={__("Tasks")}
|
||||
icon={IconInboxEmpty}
|
||||
to={`${prefix}/tasks`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listMeasures">
|
||||
<SidebarItem
|
||||
label={__("Measures")}
|
||||
icon={IconTodo}
|
||||
to={`${prefix}/measures`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listRisks">
|
||||
<SidebarItem
|
||||
label={__("Risks")}
|
||||
icon={IconFire3}
|
||||
to={`${prefix}/risks`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listFrameworks">
|
||||
<SidebarItem
|
||||
label={__("Frameworks")}
|
||||
icon={IconBank}
|
||||
to={`${prefix}/frameworks`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listPeople">
|
||||
<SidebarItem
|
||||
label={__("People")}
|
||||
icon={IconGroup1}
|
||||
to={`${prefix}/people`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listVendors">
|
||||
<SidebarItem
|
||||
label={__("Vendors")}
|
||||
icon={IconStore}
|
||||
to={`${prefix}/vendors`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listDocuments">
|
||||
<SidebarItem
|
||||
label={__("Documents")}
|
||||
icon={IconPageTextLine}
|
||||
to={`${prefix}/documents`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listAssets">
|
||||
<SidebarItem
|
||||
label={__("Assets")}
|
||||
icon={IconBox}
|
||||
to={`${prefix}/assets`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listData">
|
||||
<SidebarItem
|
||||
label={__("Data")}
|
||||
icon={IconListStack}
|
||||
to={`${prefix}/data`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listAudits">
|
||||
<SidebarItem
|
||||
label={__("Audits")}
|
||||
icon={IconMedal}
|
||||
to={`${prefix}/audits`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listNonconformities">
|
||||
<SidebarItem
|
||||
label={__("Nonconformities")}
|
||||
icon={IconCrossLargeX}
|
||||
to={`${prefix}/nonconformities`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listObligations">
|
||||
<SidebarItem
|
||||
label={__("Obligations")}
|
||||
icon={IconBook}
|
||||
to={`${prefix}/obligations`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listContinualImprovements">
|
||||
<SidebarItem
|
||||
label={__("Continual Improvements")}
|
||||
icon={IconRotateCw}
|
||||
to={`${prefix}/continual-improvements`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listProcessingActivities">
|
||||
<SidebarItem
|
||||
label={__("Processing Activities")}
|
||||
icon={IconCircleProgress}
|
||||
to={`${prefix}/processing-activities`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listSnapshots">
|
||||
<SidebarItem
|
||||
label={__("Snapshots")}
|
||||
icon={IconClock}
|
||||
to={`${prefix}/snapshots`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="getTrustCenter">
|
||||
<SidebarItem
|
||||
label={__("Trust Center")}
|
||||
icon={IconShield}
|
||||
to={`${prefix}/trust-center`}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="listMembers">
|
||||
<SidebarItem
|
||||
label={__("Settings")}
|
||||
icon={IconSettingsGear2}
|
||||
to={`${prefix}/settings`}
|
||||
/>
|
||||
</Authorized>
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
@@ -234,11 +287,13 @@ function UserDropdown({ organizationId }: { organizationId: string }) {
|
||||
|
||||
return (
|
||||
<UserDropdownRoot fullName={user.fullName} email={user.email}>
|
||||
<UserDropdownItem
|
||||
to="/api-keys"
|
||||
icon={IconKey}
|
||||
label={__("API Keys")}
|
||||
/>
|
||||
<Authorized entity="Organization" action="deleteOrganization">
|
||||
<UserDropdownItem
|
||||
to="/api-keys"
|
||||
icon={IconKey}
|
||||
label={__("API Keys")}
|
||||
/>
|
||||
</Authorized>
|
||||
<UserDropdownItem
|
||||
to="mailto:support@getprobo.com"
|
||||
icon={IconCircleQuestionmark}
|
||||
@@ -287,16 +342,6 @@ interface InvitationsResponse {
|
||||
invitations: Invitation[];
|
||||
}
|
||||
|
||||
function OrganizationSelectorWrapper({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {
|
||||
organizationId,
|
||||
});
|
||||
return <OrganizationSelector currentOrganization={data.organization} />;
|
||||
}
|
||||
|
||||
function OrganizationSelector({
|
||||
currentOrganization,
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function APIKeysPage() {
|
||||
try {
|
||||
const [apiKeysResponse, organizationsResponse] = await Promise.all([
|
||||
fetch('/connect/api-keys', { credentials: 'include' }),
|
||||
fetch('/connect/organizations', { credentials: 'include' }),
|
||||
fetch('/connect/organizations?role=OWNER', { credentials: 'include' }),
|
||||
]);
|
||||
|
||||
if (apiKeysResponse.status === 401 || organizationsResponse.status === 401) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import z from "zod";
|
||||
import { getAssetTypeVariant, validateSnapshotConsistency } from "@probo/helpers";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateAssetSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -114,15 +115,17 @@ export default function AssetDetailsPage(props: Props) {
|
||||
</Badge>
|
||||
</div>
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteAsset}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="Asset" action="deleteAsset">
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteAsset}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -178,9 +181,11 @@ export default function AssetDetailsPage(props: Props) {
|
||||
|
||||
<div className="flex justify-end">
|
||||
{formState.isDirty && !isSnapshotMode && (
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
<Authorized entity="Asset" action="updateAsset">
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
} from "./__generated__/AssetsPageFragment.graphql";
|
||||
import { SortableTable } from "/components/SortableTable";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const paginatedAssetsFragment = graphql`
|
||||
fragment AssetsPageFragment on Organization
|
||||
@@ -106,6 +108,11 @@ export default function AssetsPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Assets"));
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "Asset", "updateAsset") ||
|
||||
isAuthorized(organizationId, "Asset", "deleteAsset")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
@@ -116,12 +123,14 @@ export default function AssetsPage(props: Props) {
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode && (
|
||||
<CreateAssetDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
|
||||
</CreateAssetDialog>
|
||||
<Authorized entity="Organization" action="createAsset">
|
||||
<CreateAssetDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
|
||||
</CreateAssetDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
<SortableTable {...pagination}>
|
||||
@@ -132,7 +141,7 @@ export default function AssetsPage(props: Props) {
|
||||
<Th>{__("Amount")}</Th>
|
||||
<Th>{__("Owner")}</Th>
|
||||
<Th>{__("Vendors")}</Th>
|
||||
<Th></Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -141,6 +150,7 @@ export default function AssetsPage(props: Props) {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -152,9 +162,11 @@ export default function AssetsPage(props: Props) {
|
||||
function AssetRow({
|
||||
entry,
|
||||
connectionId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
entry: AssetEntry;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -204,19 +216,21 @@ function AssetRow({
|
||||
<span className="text-txt-secondary text-sm">{__("None")}</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
onClick={deleteAsset}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</Td>
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<Authorized entity="Asset" action="deleteAsset">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
onClick={deleteAsset}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import z from "zod";
|
||||
import { getAuditStateLabel, getAuditStateVariant, auditStates, fileSize, sprintf, formatDatetime, formatError, formatDate, type GraphQLError } from "@probo/helpers";
|
||||
import type { AuditGraphNodeQuery } from "/hooks/graph/__generated__/AuditGraphNodeQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateAuditSchema = z.object({
|
||||
name: z.string().nullable().optional(),
|
||||
@@ -145,13 +146,15 @@ export default function AuditDetailsPage(props: Props) {
|
||||
</Badge>
|
||||
</div>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteAudit}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Audit" action="deleteAudit">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteAudit}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
|
||||
@@ -184,9 +187,11 @@ export default function AuditDetailsPage(props: Props) {
|
||||
|
||||
<div className="flex justify-end">
|
||||
{formState.isDirty && (
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
<Authorized entity="Audit" action="updateAudit">
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
AuditsPageFragment$key,
|
||||
} from "./__generated__/AuditsPageFragment.graphql";
|
||||
import { SortableTable } from "/components/SortableTable";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const paginatedAuditsFragment = graphql`
|
||||
fragment AuditsPageFragment on Organization
|
||||
@@ -92,6 +94,9 @@ export default function AuditsPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Audits"));
|
||||
|
||||
const hasAnyAction = isAuthorized(organizationId, "Audit", "updateAudit") ||
|
||||
isAuthorized(organizationId, "Audit", "deleteAudit");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -100,12 +105,14 @@ export default function AuditsPage(props: Props) {
|
||||
"Manage your organization's compliance audits and their progress."
|
||||
)}
|
||||
>
|
||||
<CreateAuditDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add audit")}</Button>
|
||||
</CreateAuditDialog>
|
||||
<Authorized entity="Organization" action="createAudit">
|
||||
<CreateAuditDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add audit")}</Button>
|
||||
</CreateAuditDialog>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
<SortableTable {...pagination}>
|
||||
<Thead>
|
||||
@@ -116,7 +123,7 @@ export default function AuditsPage(props: Props) {
|
||||
<Th>{__("Valid From")}</Th>
|
||||
<Th>{__("Valid Until")}</Th>
|
||||
<Th>{__("Report")}</Th>
|
||||
<Th></Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -125,6 +132,7 @@ export default function AuditsPage(props: Props) {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -136,9 +144,11 @@ export default function AuditsPage(props: Props) {
|
||||
function AuditRow({
|
||||
entry,
|
||||
connectionId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
entry: AuditEntry;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -164,17 +174,21 @@ function AuditRow({
|
||||
<Badge variant="neutral">{__("Not uploaded")}</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
onClick={deleteAudit}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<Authorized entity="Audit" action="deleteAudit">
|
||||
<DropdownItem
|
||||
onClick={deleteAudit}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import z from "zod";
|
||||
import { getStatusVariant, getStatusLabel, formatDatetime, validateSnapshotConsistency } from "@probo/helpers";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import type { ContinualImprovementGraphNodeQuery } from "/hooks/graph/__generated__/ContinualImprovementGraphNodeQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateImprovementSchema = z.object({
|
||||
referenceId: z.string().min(1, "Reference ID is required"),
|
||||
@@ -149,11 +150,13 @@ export default function ContinualImprovementDetailsPage(props: Props) {
|
||||
]}
|
||||
/>
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown>
|
||||
<DropdownItem onClick={deleteImprovement} variant="danger">
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="ContinualImprovement" action="deleteContinualImprovement">
|
||||
<ActionDropdown>
|
||||
<DropdownItem onClick={deleteImprovement} variant="danger">
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -286,13 +289,15 @@ export default function ContinualImprovementDetailsPage(props: Props) {
|
||||
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
|
||||
</Button>
|
||||
<Authorized entity="ContinualImprovement" action="updateContinualImprovement">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
ContinualImprovementsPageFragment$key,
|
||||
ContinualImprovementsPageFragment$data,
|
||||
} from "./__generated__/ContinualImprovementsPageFragment.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
interface ContinualImprovementsPageProps {
|
||||
queryRef: PreloadedQuery<ContinualImprovementsPageQuery>;
|
||||
@@ -127,6 +129,11 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
|
||||
);
|
||||
const improvements = data?.continualImprovements?.edges?.map((edge) => edge.node) ?? [];
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "ContinualImprovement", "updateContinualImprovement") ||
|
||||
isAuthorized(organizationId, "ContinualImprovement", "deleteContinualImprovement")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isSnapshotMode && snapshotId && (
|
||||
@@ -134,14 +141,16 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
|
||||
)}
|
||||
<PageHeader title={__("Continual Improvements")} description={__("Manage your continual improvements.")}>
|
||||
{!isSnapshotMode && (
|
||||
<CreateContinualImprovementDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("Add continual improvement")}
|
||||
</Button>
|
||||
</CreateContinualImprovementDialog>
|
||||
<Authorized entity="Organization" action="createContinualImprovement">
|
||||
<CreateContinualImprovementDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("Add continual improvement")}
|
||||
</Button>
|
||||
</CreateContinualImprovementDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
@@ -156,7 +165,7 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
|
||||
<Th>{__("Priority")}</Th>
|
||||
<Th>{__("Owner")}</Th>
|
||||
<Th>{__("Target Date")}</Th>
|
||||
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -166,6 +175,7 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
|
||||
improvement={improvement}
|
||||
connectionId={connectionId}
|
||||
snapshotId={snapshotId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -203,10 +213,12 @@ function ImprovementRow({
|
||||
improvement,
|
||||
connectionId,
|
||||
snapshotId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
improvement: NodeOf<NonNullable<ContinualImprovementsPageFragment$data['continualImprovements']>>;
|
||||
connectionId: string;
|
||||
snapshotId?: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -267,16 +279,18 @@ function ImprovementRow({
|
||||
<span className="text-txt-tertiary">{__("No target date")}</span>
|
||||
)}
|
||||
</Td>
|
||||
{!isSnapshotMode && (
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="ContinualImprovement" action="deleteContinualImprovement">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
import type { DataListQuery } from "./__generated__/DataListQuery.graphql";
|
||||
import { SortableTable } from "/components/SortableTable";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const paginatedDataFragment = graphql`
|
||||
fragment DataPageFragment on Organization
|
||||
@@ -119,6 +121,10 @@ export default function DataPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Data"));
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && ( isAuthorized(organizationId, "Datum", "updateDatum") ||
|
||||
isAuthorized(organizationId, "Datum", "deleteDatum")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isSnapshotMode && snapshotId && (
|
||||
@@ -131,13 +137,15 @@ export default function DataPage(props: Props) {
|
||||
)}
|
||||
>
|
||||
{!snapshotId && (
|
||||
<CreateDatumDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
onCreated={() => pagination.refetch({ snapshotId })}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
|
||||
</CreateDatumDialog>
|
||||
<Authorized entity="Organization" action="createDatum">
|
||||
<CreateDatumDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
onCreated={() => pagination.refetch({ snapshotId })}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
|
||||
</CreateDatumDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
<SortableTable
|
||||
@@ -150,12 +158,12 @@ export default function DataPage(props: Props) {
|
||||
<Th>{__("Classification")}</Th>
|
||||
<Th>{__("Owner")}</Th>
|
||||
<Th>{__("Vendors")}</Th>
|
||||
<Th></Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{dataEntries.map((entry) => (
|
||||
<DataRow key={entry.id} entry={entry} connectionId={connectionId} snapshotId={snapshotId} />
|
||||
<DataRow key={entry.id} entry={entry} connectionId={connectionId} snapshotId={snapshotId} hasAnyAction={hasAnyAction} />
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
@@ -167,10 +175,12 @@ function DataRow({
|
||||
entry,
|
||||
connectionId,
|
||||
snapshotId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
entry: DataEntry;
|
||||
connectionId: string;
|
||||
snapshotId?: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -215,18 +225,21 @@ function DataRow({
|
||||
<span className="text-txt-secondary text-sm">{__("None")}</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
{!snapshotId && (<ActionDropdown>
|
||||
<DropdownItem
|
||||
onClick={deleteDatum}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<Authorized entity="Datum" action="deleteDatum">
|
||||
<DropdownItem
|
||||
onClick={deleteDatum}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</Td>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import z from "zod";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { validateSnapshotConsistency } from "@probo/helpers";
|
||||
import type { DatumGraphNodeQuery } from "/hooks/graph/__generated__/DatumGraphNodeQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateDatumSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -124,15 +125,17 @@ export default function DatumDetailsPage(props: Props) {
|
||||
<Badge variant="info">{datumEntry?.dataClassification}</Badge>
|
||||
</div>
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteDatum}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="Datum" action="deleteDatum">
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteDatum}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -177,9 +180,11 @@ export default function DatumDetailsPage(props: Props) {
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex justify-end">
|
||||
{formState.isDirty && (
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
<Authorized entity="Datum" action="updateDatum">
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,6 +76,7 @@ import { DocumentTypeOptions } from "/components/form/DocumentTypeOptions";
|
||||
import { DocumentClassificationOptions } from "/components/form/DocumentClassificationOptions";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<DocumentGraphNodeQuery>;
|
||||
@@ -521,20 +522,24 @@ export default function DocumentDetailPage(props: Props) {
|
||||
</Dropdown>
|
||||
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
onClick={() => updateDialogRef.current?.open()}
|
||||
icon={IconPencil}
|
||||
>
|
||||
{isDraft ? __("Edit draft document") : __("Create new draft")}
|
||||
</DropdownItem>
|
||||
{isDraft && versions.length > 1 && (
|
||||
<Authorized entity="Document" action="updateDocument">
|
||||
<DropdownItem
|
||||
onClick={handleDeleteDraft}
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeletingDraft}
|
||||
onClick={() => updateDialogRef.current?.open()}
|
||||
icon={IconPencil}
|
||||
>
|
||||
{__("Delete draft document")}
|
||||
{isDraft ? __("Edit draft document") : __("Create new draft")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
{isDraft && versions.length > 1 && (
|
||||
<Authorized entity="Document" action="deleteDocument">
|
||||
<DropdownItem
|
||||
onClick={handleDeleteDraft}
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeletingDraft}
|
||||
>
|
||||
{__("Delete draft document")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
)}
|
||||
<DropdownItem
|
||||
onClick={() => pdfDownloadDialogRef.current?.open()}
|
||||
@@ -543,14 +548,16 @@ export default function DocumentDetailPage(props: Props) {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete document")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Document" action="deleteDocument">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete document")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
type BulkExportDialogRef,
|
||||
} from "/components/documents/BulkExportDialog";
|
||||
import type { DocumentsPageUserEmailQuery } from "./__generated__/DocumentsPageUserEmailQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const documentsFragment = graphql`
|
||||
fragment DocumentsPageListFragment on Organization
|
||||
@@ -135,6 +137,9 @@ export default function DocumentsPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
const hasAnyAction = isAuthorized(organization.id, "Document", "updateDocument") ||
|
||||
isAuthorized(organization.id, "Document", "deleteDocument");
|
||||
|
||||
const handleSendSigningNotifications = () => {
|
||||
sendSigningNotifications({
|
||||
variables: {
|
||||
@@ -193,6 +198,7 @@ export default function DocumentsPage(props: Props) {
|
||||
description={__("Manage your organization's documents")}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Authorized entity="Document" action="sendSigningNotifications">
|
||||
<Button
|
||||
icon={IconBell2}
|
||||
variant="secondary"
|
||||
@@ -200,10 +206,13 @@ export default function DocumentsPage(props: Props) {
|
||||
>
|
||||
{__("Send signing notifications")}
|
||||
</Button>
|
||||
<CreateDocumentDialog
|
||||
connection={connectionId}
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New document")}</Button>}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="createDocument">
|
||||
<CreateDocumentDialog
|
||||
connection={connectionId}
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New document")}</Button>}
|
||||
/>
|
||||
</Authorized>
|
||||
</div>
|
||||
</PageHeader>
|
||||
{documents.length > 0 ? (
|
||||
@@ -232,7 +241,7 @@ export default function DocumentsPage(props: Props) {
|
||||
<Th className="w-60">{__("Owner")}</Th>
|
||||
<Th className="w-60">{__("Last update")}</Th>
|
||||
<Th className="w-20">{__("Signatures")}</Th>
|
||||
<Th className="w-18"></Th>
|
||||
{hasAnyAction && <Th className="w-18"></Th>}
|
||||
</Tr>
|
||||
) : (
|
||||
<Tr>
|
||||
@@ -249,29 +258,33 @@ export default function DocumentsPage(props: Props) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<PublishDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
<Button
|
||||
icon={IconCheckmark1}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
<Authorized entity="Document" action="updateDocument">
|
||||
<PublishDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
<SignatureDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconSignature}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
<Button
|
||||
icon={IconCheckmark1}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
</Authorized>
|
||||
<Authorized entity="Document" action="bulkRequestSignatures">
|
||||
<SignatureDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</SignatureDocumentsDialog>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconSignature}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</SignatureDocumentsDialog>
|
||||
</Authorized>
|
||||
<BulkExportDialog
|
||||
ref={bulkExportDialogRef}
|
||||
onExport={handleBulkExport}
|
||||
@@ -287,14 +300,16 @@ export default function DocumentsPage(props: Props) {
|
||||
{__("Export")}
|
||||
</Button>
|
||||
</BulkExportDialog>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
<Authorized entity="Document" action="deleteDocument">
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
</div>
|
||||
</Th>
|
||||
@@ -310,6 +325,7 @@ export default function DocumentsPage(props: Props) {
|
||||
document={document}
|
||||
organizationId={organization.id}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -367,12 +383,14 @@ function DocumentRow({
|
||||
organizationId,
|
||||
checked,
|
||||
onCheck,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
document: DocumentsPageRowFragment$key;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
checked: boolean;
|
||||
onCheck: () => void;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const document = useFragment<DocumentsPageRowFragment$key>(
|
||||
rowFragment,
|
||||
@@ -444,17 +462,21 @@ function DocumentRow({
|
||||
<Td className="w-20">
|
||||
{signedCount}/{signatures.length}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end w-18">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end w-18">
|
||||
<ActionDropdown>
|
||||
<Authorized entity="Document" action="deleteDocument">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useOutletContext } from "react-router";
|
||||
import type { DocumentSignaturesTab_signature$key } from "/pages/organizations/documents/tabs/__generated__/DocumentSignaturesTab_signature.graphql.ts";
|
||||
import type { DocumentSignaturesTab_version$key } from "/pages/organizations/documents/tabs/__generated__/DocumentSignaturesTab_version.graphql.ts";
|
||||
import type { DocumentSignaturesTabRefetchQuery } from "./__generated__/DocumentSignaturesTabRefetchQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Version = NodeOf<DocumentDetailPageDocumentFragment$data["versions"]>;
|
||||
|
||||
@@ -274,6 +275,7 @@ function SignatureItem(props: {
|
||||
</div>
|
||||
</div>
|
||||
{props.signable && (
|
||||
<Authorized entity="Document" action="requestSignature">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
@@ -292,6 +294,7 @@ function SignatureItem(props: {
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -328,6 +331,7 @@ function SignatureItem(props: {
|
||||
{__("Signed")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Authorized entity="DocumentVersionSignature" action="cancelSignatureRequest">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="ml-auto"
|
||||
@@ -345,6 +349,7 @@ function SignatureItem(props: {
|
||||
>
|
||||
{__("Cancel request")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { promisifyMutation } from "@probo/helpers";
|
||||
import type { FrameworkGraphControlNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphControlNodeQuery.graphql";
|
||||
import { frameworkControlNodeQuery } from "/hooks/graph/FrameworkGraph";
|
||||
import type { FrameworkDetailPageFragment$data } from "./__generated__/FrameworkDetailPageFragment.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const attachMeasureMutation = graphql`
|
||||
mutation FrameworkControlPageAttachMutation(
|
||||
@@ -235,24 +236,28 @@ export default function FrameworkControlPage({ queryRef }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FrameworkControlDialog
|
||||
frameworkId={framework.id}
|
||||
connectionId={connectionId}
|
||||
control={control}
|
||||
>
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit control")}
|
||||
</Button>
|
||||
</FrameworkControlDialog>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
<Authorized entity="Control" action="updateControl">
|
||||
<FrameworkControlDialog
|
||||
frameworkId={framework.id}
|
||||
connectionId={connectionId}
|
||||
control={control}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit control")}
|
||||
</Button>
|
||||
</FrameworkControlDialog>
|
||||
</Authorized>
|
||||
<Authorized entity="Control" action="deleteControl">
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { FrameworkDetailPageExportFrameworkMutation } from "./__generated__
|
||||
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
|
||||
import { FrameworkControlDialog } from "./dialogs/FrameworkControlDialog";
|
||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const frameworkDetailFragment = graphql`
|
||||
fragment FrameworkDetailPageFragment on Framework {
|
||||
@@ -149,14 +150,16 @@ export default function FrameworkDetailPage(props: Props) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FrameworkFormDialog
|
||||
organizationId={organizationId}
|
||||
framework={framework}
|
||||
>
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
</FrameworkFormDialog>
|
||||
<Authorized entity="Framework" action="updateFramework">
|
||||
<FrameworkFormDialog
|
||||
organizationId={organizationId}
|
||||
framework={framework}
|
||||
>
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
</FrameworkFormDialog>
|
||||
</Authorized>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="primary"
|
||||
@@ -188,9 +191,11 @@ export default function FrameworkDetailPage(props: Props) {
|
||||
>
|
||||
{__("Export Framework")}
|
||||
</DropdownItem>
|
||||
<DropdownItem icon={IconTrashCan} variant="danger" onClick={onDelete}>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Framework" action="deleteFramework">
|
||||
<DropdownItem icon={IconTrashCan} variant="danger" onClick={onDelete}>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</PageHeader>
|
||||
<div className="text-lg font-semibold">
|
||||
@@ -211,15 +216,17 @@ export default function FrameworkDetailPage(props: Props) {
|
||||
active={selectedControl?.id === control.id}
|
||||
/>
|
||||
))}
|
||||
<FrameworkControlDialog
|
||||
frameworkId={framework.id}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<button className="flex gap-[6px] flex-col w-full p-4 space-y-[6px] rounded-xl cursor-pointer text-start text-sm text-txt-tertiary hover:bg-tertiary-hover">
|
||||
<IconPlusLarge size={20} className="text-txt-primary" />
|
||||
{__("Add new control")}
|
||||
</button>
|
||||
</FrameworkControlDialog>
|
||||
<Authorized entity="Organization" action="createControl">
|
||||
<FrameworkControlDialog
|
||||
frameworkId={framework.id}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<button className="flex gap-[6px] flex-col w-full p-4 space-y-[6px] rounded-xl cursor-pointer text-start text-sm text-txt-tertiary hover:bg-tertiary-hover">
|
||||
<IconPlusLarge size={20} className="text-txt-primary" />
|
||||
{__("Add new control")}
|
||||
</button>
|
||||
</FrameworkControlDialog>
|
||||
</Authorized>
|
||||
</div>
|
||||
<Outlet context={{ framework }} />
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import { useState, type ChangeEventHandler } from "react";
|
||||
import { FrameworkLogo } from "/components/FrameworkLogo";
|
||||
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<FrameworkGraphListQuery>;
|
||||
@@ -118,6 +120,9 @@ export default function FrameworksPage(props: Props) {
|
||||
|
||||
const isLoading = isUploading || isImporting;
|
||||
|
||||
const hasAnyAction = isAuthorized(data.organization.id!, "Framework", "updateFramework") ||
|
||||
isAuthorized(data.organization.id!, "Framework", "deleteFramework");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FrameworkFormDialog
|
||||
@@ -129,18 +134,20 @@ export default function FrameworksPage(props: Props) {
|
||||
title={__("Frameworks")}
|
||||
description={__("Manage your compliance frameworks")}
|
||||
>
|
||||
<FileButton
|
||||
variant="secondary"
|
||||
icon={IconFolderUpload}
|
||||
onChange={handleUpload}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{__("Import")}
|
||||
</FileButton>
|
||||
<FrameworkSelector
|
||||
onSelect={importNamedFramework}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Authorized entity="Organization" action="createFramework">
|
||||
<FileButton
|
||||
variant="secondary"
|
||||
icon={IconFolderUpload}
|
||||
onChange={handleUpload}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{__("Import")}
|
||||
</FileButton>
|
||||
<FrameworkSelector
|
||||
onSelect={importNamedFramework}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -150,6 +157,7 @@ export default function FrameworksPage(props: Props) {
|
||||
connectionId={connectionId}
|
||||
key={framework.id}
|
||||
framework={framework}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -169,6 +177,7 @@ type FrameworkCardProps = {
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
framework: FrameworksPageCardFragment$key;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function FrameworkCard(props: FrameworkCardProps) {
|
||||
@@ -189,23 +198,29 @@ function FrameworkCard(props: FrameworkCardProps) {
|
||||
/>
|
||||
<div className="flex justify-between mb-3">
|
||||
<FrameworkLogo {...framework} />
|
||||
<ActionDropdown className="z-10 relative">
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => {
|
||||
dialogRef.current?.open();
|
||||
}}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={() => deleteFramework()}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
{props.hasAnyAction && (
|
||||
<ActionDropdown className="z-10 relative">
|
||||
<Authorized entity="Framework" action="updateFramework">
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => {
|
||||
dialogRef.current?.open();
|
||||
}}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
<Authorized entity="Framework" action="deleteFramework">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={() => deleteFramework()}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-medium">
|
||||
<Link
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
sprintf,
|
||||
} from "@probo/helpers";
|
||||
import MeasureFormDialog from "./dialog/MeasureFormDialog";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<MeasureGraphNodeQuery>;
|
||||
@@ -135,29 +136,33 @@ export default function MeasureDetailPage(props: Props) {
|
||||
/>
|
||||
|
||||
<PageHeader title={measure.name} description={measure.description}>
|
||||
<MeasureFormDialog measure={measure}>
|
||||
<Button variant="secondary" icon={IconPencil}>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
</MeasureFormDialog>
|
||||
<Select
|
||||
disabled={isUpdating}
|
||||
onValueChange={onStateChange}
|
||||
name="state"
|
||||
placeholder={__("Select state")}
|
||||
className="rounded-full"
|
||||
value={measure.state}
|
||||
>
|
||||
{measureStates.map((state) => (
|
||||
<Option key={state} value={state}>
|
||||
{getMeasureStateLabel(__, state)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Authorized entity="Measure" action="updateMeasure">
|
||||
<MeasureFormDialog measure={measure}>
|
||||
<Button variant="secondary" icon={IconPencil}>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
</MeasureFormDialog>
|
||||
<Select
|
||||
disabled={isUpdating}
|
||||
onValueChange={onStateChange}
|
||||
name="state"
|
||||
placeholder={__("Select state")}
|
||||
className="rounded-full"
|
||||
value={measure.state}
|
||||
>
|
||||
{measureStates.map((state) => (
|
||||
<Option key={state} value={state}>
|
||||
{getMeasureStateLabel(__, state)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Authorized>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem variant="danger" icon={IconTrashCan} onClick={onDelete}>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Measure" action="deleteMeasure">
|
||||
<DropdownItem variant="danger" icon={IconTrashCan} onClick={onDelete}>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { Link, useParams } from "react-router";
|
||||
import MeasureFormDialog from "./dialog/MeasureFormDialog";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<MeasureGraphListQuery>;
|
||||
@@ -113,6 +115,9 @@ export default function MeasuresPage(props: Props) {
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
usePageTitle(__("Measures"));
|
||||
|
||||
const hasAnyAction = isAuthorized(organization.id, "Measure", "updateMeasure") ||
|
||||
isAuthorized(organization.id, "Measure", "deleteMeasure");
|
||||
|
||||
const handleImport: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
@@ -143,19 +148,21 @@ export default function MeasuresPage(props: Props) {
|
||||
"Measures are actions taken to reduce the risk. Add them to track their implementation status."
|
||||
)}
|
||||
>
|
||||
<FileButton
|
||||
ref={importFileRef}
|
||||
variant="secondary"
|
||||
icon={IconFolderUpload}
|
||||
onChange={handleImport}
|
||||
>
|
||||
{__("Import")}
|
||||
</FileButton>
|
||||
<MeasureFormDialog connection={connectionId}>
|
||||
<Button variant="primary" icon={IconPlusLarge}>
|
||||
{__("New measure")}
|
||||
</Button>
|
||||
</MeasureFormDialog>
|
||||
<Authorized entity="Organization" action="createMeasure">
|
||||
<FileButton
|
||||
ref={importFileRef}
|
||||
variant="secondary"
|
||||
icon={IconFolderUpload}
|
||||
onChange={handleImport}
|
||||
>
|
||||
{__("Import")}
|
||||
</FileButton>
|
||||
<MeasureFormDialog connection={connectionId}>
|
||||
<Button variant="primary" icon={IconPlusLarge}>
|
||||
{__("New measure")}
|
||||
</Button>
|
||||
</MeasureFormDialog>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
<MeasureImplementation measures={measures} className="my-10" />
|
||||
{objectKeys(measuresPerCategory)
|
||||
@@ -166,6 +173,7 @@ export default function MeasuresPage(props: Props) {
|
||||
category={category}
|
||||
measures={measuresPerCategory[category]}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -176,6 +184,7 @@ type CategoryProps = {
|
||||
category: string;
|
||||
measures: NodeOf<MeasuresPageFragment$data["measures"]>[];
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function Category(props: CategoryProps) {
|
||||
@@ -219,7 +228,7 @@ function Category(props: CategoryProps) {
|
||||
<Tr>
|
||||
<Th>{__("Measure")}</Th>
|
||||
<Th>{__("State")}</Th>
|
||||
<Th></Th>
|
||||
{props.hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -228,6 +237,7 @@ function Category(props: CategoryProps) {
|
||||
key={measure.id}
|
||||
measure={measure}
|
||||
connectionId={props.connectionId}
|
||||
hasAnyAction={props.hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -251,6 +261,7 @@ function Category(props: CategoryProps) {
|
||||
type MeasureRowProps = {
|
||||
measure: NodeOf<MeasuresPageFragment$data["measures"]>;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function MeasureRow(props: MeasureRowProps) {
|
||||
@@ -292,24 +303,30 @@ function MeasureRow(props: MeasureRowProps) {
|
||||
<Td width={120}>
|
||||
<MeasureBadge state={props.measure.state} />
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
{props.hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<Authorized entity="Measure" action="updateMeasure">
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
<Authorized entity="Measure" action="deleteMeasure">
|
||||
<DropdownItem
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
UpdateMeetingMinutesDialog,
|
||||
type UpdateMeetingMinutesDialogRef,
|
||||
} from "./dialogs/UpdateMeetingMinutesDialog";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import {
|
||||
meetingNodeQuery,
|
||||
useDeleteMeetingMutation,
|
||||
} from "/hooks/graph/MeetingGraph";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const meetingFragment = graphql`
|
||||
fragment MeetingDetailPageMeetingFragment on Meeting {
|
||||
@@ -62,6 +63,63 @@ export default function MeetingDetailPage(props: Props) {
|
||||
const confirm = useConfirm();
|
||||
const updateMinutesDialogRef = useRef<UpdateMeetingMinutesDialogRef>(null);
|
||||
|
||||
const [canUpdate, setCanUpdate] = useState<boolean>(false);
|
||||
const [canDelete, setCanDelete] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setCanUpdate(false);
|
||||
setCanDelete(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updateAuth = isAuthorized(organizationId, "Meeting", "updateMeeting");
|
||||
setCanUpdate(updateAuth);
|
||||
} catch (promise) {
|
||||
if (promise instanceof Promise) {
|
||||
promise
|
||||
.then(() => {
|
||||
try {
|
||||
const updateAuth = isAuthorized(organizationId, "Meeting", "updateMeeting");
|
||||
setCanUpdate(updateAuth);
|
||||
} catch {
|
||||
setCanUpdate(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setCanUpdate(false);
|
||||
});
|
||||
} else {
|
||||
setCanUpdate(false);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const deleteAuth = isAuthorized(organizationId, "Meeting", "deleteMeeting");
|
||||
setCanDelete(deleteAuth);
|
||||
} catch (promise) {
|
||||
if (promise instanceof Promise) {
|
||||
promise
|
||||
.then(() => {
|
||||
try {
|
||||
const deleteAuth = isAuthorized(organizationId, "Meeting", "deleteMeeting");
|
||||
setCanDelete(deleteAuth);
|
||||
} catch {
|
||||
setCanDelete(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setCanDelete(false);
|
||||
});
|
||||
} else {
|
||||
setCanDelete(false);
|
||||
}
|
||||
}
|
||||
}, [organizationId]);
|
||||
|
||||
const hasAnyAction = canUpdate || canDelete;
|
||||
|
||||
usePageTitle(meeting.name);
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -105,22 +163,28 @@ export default function MeetingDetailPage(props: Props) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
onClick={() => updateMinutesDialogRef.current?.open()}
|
||||
icon={IconPencil}
|
||||
>
|
||||
{__("Edit minutes")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete meeting")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
{hasAnyAction && (
|
||||
<ActionDropdown variant="secondary">
|
||||
{canUpdate && (
|
||||
<DropdownItem
|
||||
onClick={() => updateMinutesDialogRef.current?.open()}
|
||||
icon={IconPencil}
|
||||
>
|
||||
{__("Edit minutes")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete meeting")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
title={meeting.name}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Link } from "react-router";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import type { MeetingsPage_UpdateSummaryMutation } from "./__generated__/MeetingsPage_UpdateSummaryMutation.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const meetingsFragment = graphql`
|
||||
fragment MeetingsPageListFragment on Organization
|
||||
@@ -217,13 +218,15 @@ export default function MeetingsPage(props: Props) {
|
||||
<h3 className="text-sm font-semibold text-txt-secondary">
|
||||
{__("Summary")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
<Authorized entity="Meeting" action="updateMeeting">
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{displayedSummary ? (
|
||||
@@ -245,9 +248,11 @@ export default function MeetingsPage(props: Props) {
|
||||
"Track and manage your organization's meetings and their minutes."
|
||||
)}
|
||||
>
|
||||
<CreateMeetingDialog connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
|
||||
</CreateMeetingDialog>
|
||||
<Authorized entity="Organization" action="createMeeting">
|
||||
<CreateMeetingDialog connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
|
||||
</CreateMeetingDialog>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
{meetingNodes.length > 0 ? (
|
||||
<SortableTable {...pagination}>
|
||||
@@ -365,17 +370,19 @@ function MeetingRow({
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end w-18">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
<Authorized entity="Meeting" action="deleteMeeting">
|
||||
<Td noLink width={50} className="text-end w-18">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
</Authorized>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<60e0c9d7301cff5c1df299e76debb633>>
|
||||
* @generated SignedSource<<1fff8c5cca1610284185c485630e84de>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -19,6 +19,7 @@ export type MeetingsPage_UpdateSummaryMutation$variables = {
|
||||
export type MeetingsPage_UpdateSummaryMutation$data = {
|
||||
readonly updateOrganizationContext: {
|
||||
readonly context: {
|
||||
readonly organizationId: string;
|
||||
readonly summary: string | null | undefined;
|
||||
};
|
||||
};
|
||||
@@ -59,6 +60,13 @@ v1 = [
|
||||
"name": "context",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "organizationId",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
@@ -91,16 +99,16 @@ return {
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "e6bead1dde5239f3cfd2fa1440191454",
|
||||
"cacheID": "cb37cdde6dc5ac655a7cefc63d9e72e7",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingsPage_UpdateSummaryMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation MeetingsPage_UpdateSummaryMutation(\n $input: UpdateOrganizationContextInput!\n) {\n updateOrganizationContext(input: $input) {\n context {\n summary\n }\n }\n}\n"
|
||||
"text": "mutation MeetingsPage_UpdateSummaryMutation(\n $input: UpdateOrganizationContextInput!\n) {\n updateOrganizationContext(input: $input) {\n context {\n organizationId\n summary\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "f354a34b18a449f02508c45d0e7d9dc5";
|
||||
(node as any).hash = "8bfa5b636dbc3535dbed22e1869bc941";
|
||||
|
||||
export default node;
|
||||
|
||||
@@ -31,6 +31,8 @@ import { deleteNonconformityMutation, NonconformitiesConnectionKey } from "../..
|
||||
import { sprintf, promisifyMutation, getStatusVariant, getStatusLabel, formatDate } from "@probo/helpers";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { useParams } from "react-router";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
import type { NonconformitiesPageQuery } from "./__generated__/NonconformitiesPageQuery.graphql";
|
||||
import type {
|
||||
NonconformitiesPageFragment$key,
|
||||
@@ -129,6 +131,11 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
|
||||
);
|
||||
const nonconformities: Nonconformity[] = nonconformitiesData?.nonconformities?.edges?.map((edge) => edge.node) ?? [];
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "Nonconformity", "updateNonconformity") ||
|
||||
isAuthorized(organizationId, "Nonconformity", "deleteNonconformity")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isSnapshotMode && (
|
||||
@@ -141,9 +148,11 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode && (
|
||||
<CreateNonconformityDialog organizationId={organizationId} connection={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add nonconformity")}</Button>
|
||||
</CreateNonconformityDialog>
|
||||
<Authorized entity="Organization" action="createNonconformity">
|
||||
<CreateNonconformityDialog organizationId={organizationId} connection={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add nonconformity")}</Button>
|
||||
</CreateNonconformityDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
@@ -169,7 +178,7 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
|
||||
<Th>{__("Audit")}</Th>
|
||||
<Th>{__("Owner")}</Th>
|
||||
<Th>{__("Due Date")}</Th>
|
||||
{!isSnapshotMode && (<Th>{__("Actions")}</Th>)}
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -180,6 +189,7 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
|
||||
connectionId={connectionId}
|
||||
isSnapshotMode={isSnapshotMode}
|
||||
snapshotId={snapshotId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -209,11 +219,13 @@ function NonconformityRow({
|
||||
connectionId,
|
||||
isSnapshotMode,
|
||||
snapshotId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
nonconformity: Nonconformity;
|
||||
connectionId: string;
|
||||
isSnapshotMode: boolean;
|
||||
snapshotId?: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -283,15 +295,18 @@ function NonconformityRow({
|
||||
<span className="text-txt-tertiary">{__("No due date")}</span>
|
||||
)}
|
||||
</Td>
|
||||
{!isSnapshotMode && (<Td noLink width={50} className="text-end">
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={() => handleDeleteNonconformity(nonconformity)}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Nonconformity" action="deleteNonconformity">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={() => handleDeleteNonconformity(nonconformity)}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import z from "zod";
|
||||
import { getStatusVariant, getStatusLabel, formatDatetime, validateSnapshotConsistency, getStatusOptions, formatError, type GraphQLError } from "@probo/helpers";
|
||||
import type { NonconformityGraphNodeQuery } from "/hooks/graph/__generated__/NonconformityGraphNodeQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateNonconformitySchema = z.object({
|
||||
referenceId: z.string().min(1, "Reference ID is required"),
|
||||
@@ -161,13 +162,15 @@ export default function NonconformityDetailsPage(props: Props) {
|
||||
</div>
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteNonconformity}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Nonconformity" action="deleteNonconformity">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteNonconformity}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
@@ -276,9 +279,11 @@ export default function NonconformityDetailsPage(props: Props) {
|
||||
|
||||
<div className="flex justify-end">
|
||||
{formState.isDirty && !isSnapshotMode && (
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
<Authorized entity="Nonconformity" action="updateNonconformity">
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,7 @@ import z from "zod";
|
||||
import { getObligationStatusVariant, getObligationStatusLabel, formatDatetime, getObligationStatusOptions, validateSnapshotConsistency } from "@probo/helpers";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import type { ObligationGraphNodeQuery } from "/hooks/graph/__generated__/ObligationGraphNodeQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateObligationSchema = z.object({
|
||||
area: z.string().optional(),
|
||||
@@ -156,11 +157,13 @@ export default function ObligationDetailsPage(props: Props) {
|
||||
</div>
|
||||
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown>
|
||||
<DropdownItem icon={IconTrashCan} onClick={deleteObligation}>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="Obligation" action="deleteObligation">
|
||||
<ActionDropdown>
|
||||
<DropdownItem icon={IconTrashCan} onClick={deleteObligation}>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -298,12 +301,14 @@ export default function ObligationDetailsPage(props: Props) {
|
||||
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
|
||||
</Button>
|
||||
<Authorized entity="Obligation" action="updateObligation">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -31,6 +31,8 @@ import { deleteObligationMutation } from "../../../hooks/graph/ObligationGraph";
|
||||
import { promisifyMutation, getObligationStatusVariant, getObligationStatusLabel, formatDate } from "@probo/helpers";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import type { ObligationsPageQuery } from "./__generated__/ObligationsPageQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
import type {
|
||||
ObligationsPageFragment$key,
|
||||
ObligationsPageFragment$data,
|
||||
@@ -117,6 +119,11 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
||||
const connectionId = obligationsData?.obligations?.__id || "";
|
||||
const obligations: Obligation[] = obligationsData?.obligations?.edges?.map((edge) => edge.node) ?? [];
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "Obligation", "updateObligation") ||
|
||||
isAuthorized(organizationId, "Obligation", "deleteObligation")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isSnapshotMode && snapshotId && (
|
||||
@@ -129,9 +136,11 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
||||
)}
|
||||
>
|
||||
{!snapshotId && (
|
||||
<CreateObligationDialog organizationId={organizationId} connection={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
|
||||
</CreateObligationDialog>
|
||||
<Authorized entity="Organization" action="createObligation">
|
||||
<CreateObligationDialog organizationId={organizationId} connection={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
|
||||
</CreateObligationDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
@@ -156,7 +165,7 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("Owner")}</Th>
|
||||
<Th>{__("Due Date")}</Th>
|
||||
<Th>{__("Actions")}</Th>
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -166,6 +175,7 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
||||
obligation={obligation}
|
||||
connectionId={connectionId}
|
||||
snapshotId={snapshotId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -192,10 +202,12 @@ function ObligationRow({
|
||||
obligation,
|
||||
connectionId,
|
||||
snapshotId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
obligation: Obligation;
|
||||
connectionId: string;
|
||||
snapshotId?: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -246,19 +258,21 @@ function ObligationRow({
|
||||
<span className="text-txt-tertiary">{__("No due date")}</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
{!isSnapshotMode && (
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Obligation" action="deleteObligation">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</Td>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { Outlet } from "react-router";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<PeopleGraphNodeQuery>;
|
||||
@@ -54,15 +55,17 @@ export default function PeopleDetailPage(props: Props) {
|
||||
<Avatar name={people.fullName ?? ""} size="xl" />
|
||||
<div className="text-2xl">{people.fullName}</div>
|
||||
</div>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deletePeople}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="People" action="deletePeople">
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deletePeople}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { usePageTitle } from "@probo/hooks";
|
||||
import { getRole } from "@probo/helpers";
|
||||
import { CreatePeopleDialog } from "./dialogs/CreatePeopleDialog";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
type People = NodeOf<PeopleGraphPaginatedFragment$data["peoples"]>;
|
||||
|
||||
@@ -40,11 +42,15 @@ export default function PeopleListPage({
|
||||
queryRef: PreloadedQuery<PeopleGraphPaginatedQuery>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { people, refetch, connectionId, hasNext, loadNext, isLoadingNext } =
|
||||
usePeopleQuery(queryRef);
|
||||
|
||||
usePageTitle(__("Members"));
|
||||
|
||||
const hasAnyAction = isAuthorized(organizationId, "People", "updatePeople") ||
|
||||
isAuthorized(organizationId, "People", "deletePeople");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -53,9 +59,11 @@ export default function PeopleListPage({
|
||||
"Keep track of your company's workforce and their progress towards completing tasks assigned to them."
|
||||
)}
|
||||
>
|
||||
<CreatePeopleDialog connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add member")}</Button>
|
||||
</CreatePeopleDialog>
|
||||
<Authorized entity="Organization" action="createPeople">
|
||||
<CreatePeopleDialog connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add member")}</Button>
|
||||
</CreatePeopleDialog>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
<SortableTable
|
||||
refetch={refetch}
|
||||
@@ -68,7 +76,7 @@ export default function PeopleListPage({
|
||||
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
||||
<SortableTh field="KIND">{__("Role")}</SortableTh>
|
||||
<Th>{__("Position")}</Th>
|
||||
<Th>{__("Actions")}</Th>
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -77,6 +85,7 @@ export default function PeopleListPage({
|
||||
key={person.id}
|
||||
people={person}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -88,9 +97,11 @@ export default function PeopleListPage({
|
||||
function PeopleRow({
|
||||
people,
|
||||
connectionId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
people: People;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -115,17 +126,21 @@ function PeopleRow({
|
||||
</Td>
|
||||
<Td className="text-sm">{getRole(__, people.kind)}</Td>
|
||||
<Td className="text-sm">{people.position}</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onClick={deletePeople}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<Authorized entity="People" action="deletePeople">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onClick={deletePeople}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { PeopleGraphUpdateMutation } from "/hooks/graph/__generated__/Peopl
|
||||
import { updatePeopleMutation } from "/hooks/graph/PeopleGraph";
|
||||
import { Button, Card, Field, Input } from "@probo/ui";
|
||||
import { EmailsField } from "/components/form/EmailsField";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const schema = z.object({
|
||||
fullName: z.string().min(1),
|
||||
@@ -94,9 +95,11 @@ export default function PeopleProfileTab() {
|
||||
</Card>
|
||||
<div className="flex justify-end">
|
||||
{formState.isDirty && (
|
||||
<Button type="submit" disabled={isMutating}>
|
||||
{__("Update")}
|
||||
</Button>
|
||||
<Authorized entity="People" action="updatePeople">
|
||||
<Button type="submit" disabled={isMutating}>
|
||||
{__("Update")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -32,6 +32,8 @@ import { CreateProcessingActivityDialog } from "./dialogs/CreateProcessingActivi
|
||||
import { deleteProcessingActivityMutation, ProcessingActivitiesConnectionKey } from "../../../hooks/graph/ProcessingActivityGraph";
|
||||
import { sprintf, promisifyMutation } from "@probo/helpers";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
import type { NodeOf } from "/types";
|
||||
import type { ProcessingActivitiesPageQuery } from "./__generated__/ProcessingActivitiesPageQuery.graphql";
|
||||
import type {
|
||||
@@ -126,6 +128,11 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
|
||||
);
|
||||
const activities = data?.processingActivities?.edges?.map((edge) => edge.node) ?? [];
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "ProcessingActivity", "updateProcessingActivity") ||
|
||||
isAuthorized(organizationId, "ProcessingActivity", "deleteProcessingActivity")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isSnapshotMode && snapshotId && (
|
||||
@@ -133,14 +140,16 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
|
||||
)}
|
||||
<PageHeader title={__("Processing Activities")} description={__("Manage your processing activities under GDPR")}>
|
||||
{!isSnapshotMode && (
|
||||
<CreateProcessingActivityDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("Add processing activity")}
|
||||
</Button>
|
||||
</CreateProcessingActivityDialog>
|
||||
<Authorized entity="Organization" action="createProcessingActivity">
|
||||
<CreateProcessingActivityDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("Add processing activity")}
|
||||
</Button>
|
||||
</CreateProcessingActivityDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
@@ -155,7 +164,7 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
|
||||
<Th>{__("Lawful Basis")}</Th>
|
||||
<Th>{__("Location")}</Th>
|
||||
<Th>{__("International Transfers")}</Th>
|
||||
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -164,6 +173,7 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -200,9 +210,11 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
|
||||
function ActivityRow({
|
||||
activity,
|
||||
connectionId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
activity: NodeOf<NonNullable<ProcessingActivitiesPageFragment$data['processingActivities']>>;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
@@ -255,16 +267,18 @@ function ActivityRow({
|
||||
{activity.internationalTransfers ? __("Yes") : __("No")}
|
||||
</Badge>
|
||||
</Td>
|
||||
{!isSnapshotMode && (
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="ProcessingActivity" action="deleteProcessingActivity">
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "../../../components/form/ProcessingActivityEnumOptions";
|
||||
|
||||
import type { ProcessingActivityGraphNodeQuery } from "/hooks/graph/__generated__/ProcessingActivityGraphNodeQuery.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const updateProcessingActivitySchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -170,11 +171,13 @@ export default function ProcessingActivityDetailsPage(props: Props) {
|
||||
]}
|
||||
/>
|
||||
{!isSnapshotMode && (
|
||||
<ActionDropdown>
|
||||
<DropdownItem onClick={deleteActivity} variant="danger">
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="ProcessingActivity" action="deleteProcessingActivity">
|
||||
<ActionDropdown>
|
||||
<DropdownItem onClick={deleteActivity} variant="danger">
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -407,13 +410,15 @@ export default function ProcessingActivityDetailsPage(props: Props) {
|
||||
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
|
||||
</Button>
|
||||
<Authorized entity="ProcessingActivity" action="updateProcessingActivity">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "/hooks/graph/RiskGraph";
|
||||
import type { RiskGraphNodeQuery } from "/hooks/graph/__generated__/RiskGraphNodeQuery.graphql";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<RiskGraphNodeQuery>;
|
||||
@@ -120,23 +121,27 @@ export default function RiskDetailPage(props: Props) {
|
||||
/>
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex gap-2">
|
||||
<FormRiskDialog
|
||||
trigger={
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
}
|
||||
risk={{ id: riskId, ...risk }}
|
||||
/>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="Risk" action="updateRisk">
|
||||
<FormRiskDialog
|
||||
trigger={
|
||||
<Button icon={IconPencil} variant="secondary">
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
}
|
||||
risk={{ id: riskId, ...risk }}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="Risk" action="deleteRisk">
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ import type { RiskGraphListQuery } from "/hooks/graph/__generated__/RiskGraphLis
|
||||
import type { RiskGraphFragment$data } from "/hooks/graph/__generated__/RiskGraphFragment.graphql";
|
||||
import { useParams } from "react-router";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<RiskGraphListQuery>;
|
||||
@@ -54,6 +56,11 @@ export default function RisksPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Risks"));
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "Risk", "updateRisk") ||
|
||||
isAuthorized(organizationId, "Risk", "deleteRisk")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
@@ -64,13 +71,15 @@ export default function RisksPage(props: Props) {
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode && (
|
||||
<FormRiskDialog
|
||||
connection={connectionId}
|
||||
onSuccess={() => {
|
||||
pagination.refetch({ snapshotId });
|
||||
}}
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
|
||||
/>
|
||||
<Authorized entity="Organization" action="createRisk">
|
||||
<FormRiskDialog
|
||||
connection={connectionId}
|
||||
onSuccess={() => {
|
||||
pagination.refetch({ snapshotId });
|
||||
}}
|
||||
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
|
||||
/>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
@@ -101,7 +110,7 @@ export default function RisksPage(props: Props) {
|
||||
<SortableTh field="OWNER_FULL_NAME">
|
||||
{__("Owner")}
|
||||
</SortableTh>
|
||||
<Th></Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -111,6 +120,7 @@ export default function RisksPage(props: Props) {
|
||||
key={risk.id}
|
||||
connectionId={connectionId}
|
||||
organizationId={organizationId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -123,6 +133,7 @@ type RowProps = {
|
||||
risk: NodeOf<RiskGraphFragment$data["risks"]>;
|
||||
connectionId: string;
|
||||
organizationId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function RiskRow(props: RowProps) {
|
||||
@@ -180,26 +191,30 @@ function RiskRow(props: RowProps) {
|
||||
<SeverityBadge score={risk.residualRiskScore} />
|
||||
</Td>
|
||||
<Td>{risk.owner?.fullName || __("Unassigned")}</Td>
|
||||
<Td noLink className="text-end">
|
||||
{!isSnapshotMode && (
|
||||
{props.hasAnyAction && (
|
||||
<Td noLink className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => formDialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Risk" action="updateRisk">
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => formDialogRef.current?.open()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Risk" action="deleteRisk">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</Td>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { z } from "zod";
|
||||
import type { GeneralSettingsTabFragment$key } from "./__generated__/GeneralSettingsTabFragment.graphql";
|
||||
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
|
||||
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
const generalSettingsTabFragment = graphql`
|
||||
fragment GeneralSettingsTabFragment on Organization {
|
||||
@@ -90,6 +91,9 @@ export default function GeneralSettingsTab() {
|
||||
const organization = useFragment(generalSettingsTabFragment, organizationKey);
|
||||
const deleteDialogRef = useDialogRef();
|
||||
|
||||
const canUpdate = isAuthorized(organization.id, "Organization", "updateOrganization");
|
||||
const canDelete = isAuthorized(organization.id, "Organization", "deleteOrganization");
|
||||
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [horizontalLogoPreview, setHorizontalLogoPreview] = useState<
|
||||
string | null
|
||||
@@ -267,17 +271,19 @@ export default function GeneralSettingsTab() {
|
||||
name={organization.name}
|
||||
size="xl"
|
||||
/>
|
||||
<FileButton
|
||||
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||
onChange={handleLogoChange}
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
>
|
||||
{isUpdatingOrganization
|
||||
? __("Uploading...")
|
||||
: __("Change logo")}
|
||||
</FileButton>
|
||||
{canUpdate && (
|
||||
<FileButton
|
||||
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||
onChange={handleLogoChange}
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
>
|
||||
{isUpdatingOrganization
|
||||
? __("Uploading...")
|
||||
: __("Change logo")}
|
||||
</FileButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -301,19 +307,21 @@ export default function GeneralSettingsTab() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FileButton
|
||||
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||
onChange={handleHorizontalLogoChange}
|
||||
variant="secondary"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
>
|
||||
{isUpdatingOrganization
|
||||
? __("Uploading...")
|
||||
: horizontalLogoPreview || organization.horizontalLogoUrl
|
||||
? __("Change horizontal logo")
|
||||
: __("Upload horizontal logo")}
|
||||
</FileButton>
|
||||
{organization.horizontalLogoUrl && (
|
||||
{canUpdate && (
|
||||
<FileButton
|
||||
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||
onChange={handleHorizontalLogoChange}
|
||||
variant="secondary"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
>
|
||||
{isUpdatingOrganization
|
||||
? __("Uploading...")
|
||||
: horizontalLogoPreview || organization.horizontalLogoUrl
|
||||
? __("Change horizontal logo")
|
||||
: __("Upload horizontal logo")}
|
||||
</FileButton>
|
||||
)}
|
||||
{canUpdate && organization.horizontalLogoUrl && (
|
||||
<Dialog
|
||||
ref={deleteDialogRef}
|
||||
trigger={
|
||||
@@ -357,7 +365,7 @@ export default function GeneralSettingsTab() {
|
||||
</div>
|
||||
<Field
|
||||
{...register("name")}
|
||||
readOnly={formState.isSubmitting}
|
||||
readOnly={formState.isSubmitting || !canUpdate}
|
||||
name="name"
|
||||
type="text"
|
||||
label={__("Organization name")}
|
||||
@@ -367,7 +375,7 @@ export default function GeneralSettingsTab() {
|
||||
<Label>{__("Description")}</Label>
|
||||
<Textarea
|
||||
{...register("description")}
|
||||
readOnly={formState.isSubmitting}
|
||||
readOnly={formState.isSubmitting || !canUpdate}
|
||||
name="description"
|
||||
placeholder={__("Brief description of your organization")}
|
||||
rows={3}
|
||||
@@ -376,7 +384,7 @@ export default function GeneralSettingsTab() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field
|
||||
{...register("websiteUrl")}
|
||||
readOnly={formState.isSubmitting}
|
||||
readOnly={formState.isSubmitting || !canUpdate}
|
||||
name="websiteUrl"
|
||||
type="url"
|
||||
label={__("Website URL")}
|
||||
@@ -384,7 +392,7 @@ export default function GeneralSettingsTab() {
|
||||
/>
|
||||
<Field
|
||||
{...register("email")}
|
||||
readOnly={formState.isSubmitting}
|
||||
readOnly={formState.isSubmitting || !canUpdate}
|
||||
name="email"
|
||||
type="email"
|
||||
label={__("Email")}
|
||||
@@ -395,13 +403,13 @@ export default function GeneralSettingsTab() {
|
||||
<Label>{__("Headquarter Address")}</Label>
|
||||
<Textarea
|
||||
{...register("headquarterAddress")}
|
||||
readOnly={formState.isSubmitting}
|
||||
readOnly={formState.isSubmitting || !canUpdate}
|
||||
name="headquarterAddress"
|
||||
placeholder={__("123 Main St, City, Country")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formState.isDirty && (
|
||||
{formState.isDirty && canUpdate && (
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -416,37 +424,39 @@ export default function GeneralSettingsTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-12">
|
||||
<h2 className="text-base font-medium text-red-600">
|
||||
{__("Danger Zone")}
|
||||
</h2>
|
||||
<Card padded className="border-red-200 flex items-center gap-3">
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-base font-semibold text-red-700">
|
||||
{__("Delete Organization")}
|
||||
</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Permanently delete this organization and all its data.")}{" "}
|
||||
<span className="text-red-600 font-medium">
|
||||
{__("This action cannot be undone.")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<DeleteOrganizationDialog
|
||||
organizationName={organization.name}
|
||||
onConfirm={handleDeleteOrganization}
|
||||
isDeleting={isDeletingOrganization}
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeletingOrganization}
|
||||
{canDelete && (
|
||||
<div className="space-y-4 mt-12">
|
||||
<h2 className="text-base font-medium text-red-600">
|
||||
{__("Danger Zone")}
|
||||
</h2>
|
||||
<Card padded className="border-red-200 flex items-center gap-3">
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-base font-semibold text-red-700">
|
||||
{__("Delete Organization")}
|
||||
</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Permanently delete this organization and all its data.")}{" "}
|
||||
<span className="text-red-600 font-medium">
|
||||
{__("This action cannot be undone.")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<DeleteOrganizationDialog
|
||||
organizationName={organization.name}
|
||||
onConfirm={handleDeleteOrganization}
|
||||
isDeleting={isDeletingOrganization}
|
||||
>
|
||||
{__("Delete Organization")}
|
||||
</Button>
|
||||
</DeleteOrganizationDialog>
|
||||
</Card>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeletingOrganization}
|
||||
>
|
||||
{__("Delete Organization")}
|
||||
</Button>
|
||||
</DeleteOrganizationDialog>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useState, Suspense } from "react";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { usePaginationFragment, graphql } from "react-relay";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
IconPencil,
|
||||
IconTrashCan,
|
||||
Option,
|
||||
Select,
|
||||
Spinner,
|
||||
TabBadge,
|
||||
TabItem,
|
||||
@@ -16,6 +23,7 @@ import {
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { SortableTable, SortableTh } from "/components/SortableTable";
|
||||
@@ -24,6 +32,8 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import clsx from "clsx";
|
||||
import type { NodeOf } from "/types";
|
||||
import { Authorized } from "/permissions";
|
||||
import { getAssignableRoles, getUserRole } from "/permissions";
|
||||
import type {
|
||||
MembersSettingsTabMembershipsFragment$data,
|
||||
MembersSettingsTabMembershipsFragment$key
|
||||
@@ -112,6 +122,19 @@ const removeMemberMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const updateMembershipMutation = graphql`
|
||||
mutation MembersSettingsTab_UpdateMembershipMutation(
|
||||
$input: UpdateMembershipInput!
|
||||
) {
|
||||
updateMembership(input: $input) {
|
||||
membership {
|
||||
id
|
||||
role
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteInvitationMutation = graphql`
|
||||
mutation MembersSettingsTab_DeleteInvitationMutation(
|
||||
$input: DeleteInvitationInput!
|
||||
@@ -157,12 +180,14 @@ export default function MembersSettingsTab() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
|
||||
<InviteUserDialog
|
||||
connectionId={invitationsPagination.data.invitations?.__id}
|
||||
onRefetch={refetchInvitations}
|
||||
>
|
||||
<Button variant="secondary">{__("Invite member")}</Button>
|
||||
</InviteUserDialog>
|
||||
<Authorized entity="Organization" action="inviteUser">
|
||||
<InviteUserDialog
|
||||
connectionId={invitationsPagination.data.invitations?.__id}
|
||||
onRefetch={refetchInvitations}
|
||||
>
|
||||
<Button variant="secondary">{__("Invite member")}</Button>
|
||||
</InviteUserDialog>
|
||||
</Authorized>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
@@ -351,13 +376,15 @@ function InvitationRow(props: {
|
||||
{isDeleting ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
icon={IconTrashCan}
|
||||
aria-label={__("Delete invitation")}
|
||||
/>
|
||||
<Authorized entity="Organization" action="deleteInvitation">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
icon={IconTrashCan}
|
||||
aria-label={__("Delete invitation")}
|
||||
/>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
@@ -365,19 +392,33 @@ function InvitationRow(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function MembershipRow(props: {
|
||||
function MembershipRowContent(props: {
|
||||
membership: NodeOf<MembersSettingsTabMembershipsFragment$data["memberships"]>;
|
||||
connectionId?: string;
|
||||
organizationId: string;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const availableRoles = getAssignableRoles(props.organizationId);
|
||||
const currentUserRole = getUserRole(props.organizationId);
|
||||
|
||||
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
|
||||
successMessage: __("Member removed successfully"),
|
||||
errorMessage: __("Failed to remove member"),
|
||||
});
|
||||
const [updateMembership, isUpdating] = useMutationWithToasts(updateMembershipMutation, {
|
||||
successMessage: __("Role updated successfully"),
|
||||
errorMessage: __("Failed to update role"),
|
||||
});
|
||||
const confirm = useConfirm();
|
||||
const editDialogRef = useDialogRef();
|
||||
const [isRemoved, setIsRemoved] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<string>(props.membership.role);
|
||||
|
||||
// Only OWNER can edit OWNER members
|
||||
const canEditThisRole = props.membership.role === "OWNER"
|
||||
? currentUserRole === "OWNER"
|
||||
: true;
|
||||
|
||||
if (isRemoved) {
|
||||
return null;
|
||||
@@ -409,41 +450,137 @@ function MembershipRow(props: {
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setSelectedRole(props.membership.role);
|
||||
editDialogRef.current?.open();
|
||||
};
|
||||
|
||||
const handleUpdateRole = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMembership({
|
||||
variables: {
|
||||
input: {
|
||||
memberId: props.membership.id,
|
||||
organizationId: props.organizationId,
|
||||
role: selectedRole,
|
||||
},
|
||||
},
|
||||
onCompleted: () => {
|
||||
editDialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
|
||||
<Td>
|
||||
<div className="font-semibold">{props.membership.fullName}</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
{props.membership.emailAddress}
|
||||
{props.membership.authMethod === "SAML" && (
|
||||
<Badge variant="info">SAML</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge>{props.membership.role}</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(props.membership.createdAt).toLocaleDateString()}</Td>
|
||||
<Td noLink width={80} className="text-end">
|
||||
<div
|
||||
className="flex gap-2 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isRemoving ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
icon={IconTrashCan}
|
||||
aria-label={__("Remove member")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
<>
|
||||
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
|
||||
<Td>
|
||||
<div className="font-semibold">{props.membership.fullName}</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
{props.membership.emailAddress}
|
||||
{props.membership.authMethod === "SAML" && (
|
||||
<Badge variant="info">SAML</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge>{props.membership.role}</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(props.membership.createdAt).toLocaleDateString()}</Td>
|
||||
<Td noLink width={160} className="text-end">
|
||||
<div
|
||||
className="flex gap-2 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Authorized entity="Organization" action="updateMembership">
|
||||
{canEditThisRole && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleEditClick}
|
||||
disabled={isUpdating}
|
||||
icon={IconPencil}
|
||||
aria-label={__("Edit role")}
|
||||
/>
|
||||
)}
|
||||
</Authorized>
|
||||
{isRemoving ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<Authorized entity="Organization" action="removeMember">
|
||||
{canEditThisRole && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
icon={IconTrashCan}
|
||||
aria-label={__("Remove member")}
|
||||
/>
|
||||
)}
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
<Dialog ref={editDialogRef} title={__("Edit Member Role")}>
|
||||
<form onSubmit={handleUpdateRole}>
|
||||
<DialogContent padded className="space-y-6">
|
||||
<div>
|
||||
<p className="text-txt-secondary text-sm mb-4">
|
||||
{sprintf(__("Update the role for %s"), props.membership.fullName)}
|
||||
</p>
|
||||
|
||||
<Field label={__("Role")} required>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
{availableRoles.includes("OWNER") && <Option value="OWNER">{__("Owner")}</Option>}
|
||||
{availableRoles.includes("ADMIN") && <Option value="ADMIN">{__("Admin")}</Option>}
|
||||
{availableRoles.includes("VIEWER") && <Option value="VIEWER">{__("Viewer")}</Option>}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-txt-tertiary">
|
||||
{selectedRole === "OWNER" && (
|
||||
<p>{__("Full access to everything")}</p>
|
||||
)}
|
||||
{selectedRole === "ADMIN" && (
|
||||
<p>{__("Full access except organization setup and API keys")}</p>
|
||||
)}
|
||||
{selectedRole === "VIEWER" && (
|
||||
<p>{__("Read-only access")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isUpdating || selectedRole === props.membership.role}>
|
||||
{isUpdating && <Spinner />}
|
||||
{__("Update Role")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MembershipRow(props: {
|
||||
membership: NodeOf<MembersSettingsTabMembershipsFragment$data["memberships"]>;
|
||||
connectionId?: string;
|
||||
organizationId: string;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Tr>
|
||||
<Td><Spinner size={16} /></Td>
|
||||
<Td></Td>
|
||||
<Td></Td>
|
||||
<Td></Td>
|
||||
</Tr>
|
||||
}>
|
||||
<MembershipRowContent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
useVerifyDomainMutation,
|
||||
} from "/hooks/graph/SAMLConfigurationGraph";
|
||||
import type { SAMLSettingsTabFragment$key } from "./__generated__/SAMLSettingsTabFragment.graphql";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const samlSettingsTabFragment = graphql`
|
||||
fragment SAMLSettingsTabFragment on Organization {
|
||||
@@ -371,9 +372,11 @@ export default function SAMLSettingsTab() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-base font-medium">{__("SAML Single Sign-On")}</h2>
|
||||
<Button onClick={() => handleOpenModal()}>
|
||||
{__("Add Configuration")}
|
||||
</Button>
|
||||
<Authorized entity="Organization" action="createSAMLConfiguration">
|
||||
<Button onClick={() => handleOpenModal()}>
|
||||
{__("Add Configuration")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
|
||||
{configs.length === 0 ? (
|
||||
@@ -385,9 +388,11 @@ export default function SAMLSettingsTab() {
|
||||
<p className="text-gray-600 mb-6">
|
||||
{__("Set up SAML 2.0 single sign-on for your organization by adding a configuration for each email domain.")}
|
||||
</p>
|
||||
<Button onClick={() => handleOpenModal()}>
|
||||
{__("Add Your First Configuration")}
|
||||
</Button>
|
||||
<Authorized entity="Organization" action="createSAMLConfiguration">
|
||||
<Button onClick={() => handleOpenModal()}>
|
||||
{__("Add Your First Configuration")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -452,34 +457,42 @@ export default function SAMLSettingsTab() {
|
||||
<div className="flex gap-2 justify-end">
|
||||
{config.domainVerified ? (
|
||||
<>
|
||||
<Button
|
||||
variant={config.enabled ? "danger" : "primary"}
|
||||
onClick={() => handleToggleEnabled(config)}
|
||||
disabled={isEnabling || isDisabling}
|
||||
>
|
||||
{config.enabled ? __("Disable") : __("Enable")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenModal(config)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
<Authorized entity="SAMLConfiguration" action="updateSAMLConfiguration">
|
||||
<Button
|
||||
variant={config.enabled ? "danger" : "primary"}
|
||||
onClick={() => handleToggleEnabled(config)}
|
||||
disabled={isEnabling || isDisabling}
|
||||
>
|
||||
{config.enabled ? __("Disable") : __("Enable")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
<Authorized entity="SAMLConfiguration" action="updateSAMLConfiguration">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleOpenModal(config)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleOpenModal(config)}
|
||||
>
|
||||
{__("Verify Domain")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(config)}
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
<Authorized entity="Organization" action="verifyDomain">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleOpenModal(config)}
|
||||
>
|
||||
{__("Verify Domain")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
<Authorized entity="Organization" action="deleteOrganization">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(config)}
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<8df0455e495843c9db156c36f38c97d6>>
|
||||
* @generated SignedSource<<0a1a073bbc42108dd13e235594c27457>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { ReaderFragment } from 'relay-runtime';
|
||||
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
|
||||
export type Role = "ADMIN" | "MEMBER" | "OWNER" | "VIEWER";
|
||||
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MembersSettingsTabInvitationsFragment$data = {
|
||||
readonly id: string;
|
||||
@@ -24,7 +24,7 @@ export type MembersSettingsTabInvitationsFragment$data = {
|
||||
readonly expiresAt: any;
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
readonly role: Role;
|
||||
readonly role: MembershipRole;
|
||||
readonly status: InvitationStatus;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @generated SignedSource<<ff12447635b4a42587b7d7de6ea1b4e1>>
|
||||
* @generated SignedSource<<752dcbc2d152883fa2ac09b52039f01c>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
@@ -9,7 +9,7 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReaderFragment } from 'relay-runtime';
|
||||
export type Role = "ADMIN" | "MEMBER" | "OWNER" | "VIEWER";
|
||||
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
|
||||
export type UserAuthMethod = "PASSWORD" | "SAML";
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MembersSettingsTabMembershipsFragment$data = {
|
||||
@@ -23,7 +23,7 @@ export type MembersSettingsTabMembershipsFragment$data = {
|
||||
readonly emailAddress: string;
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
readonly role: Role;
|
||||
readonly role: MembershipRole;
|
||||
};
|
||||
}>;
|
||||
readonly totalCount: number;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @generated SignedSource<<562744e07bdb502c26aebe267b181b0e>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
|
||||
export type UpdateMembershipInput = {
|
||||
memberId: string;
|
||||
organizationId: string;
|
||||
role: MembershipRole;
|
||||
};
|
||||
export type MembersSettingsTab_UpdateMembershipMutation$variables = {
|
||||
input: UpdateMembershipInput;
|
||||
};
|
||||
export type MembersSettingsTab_UpdateMembershipMutation$data = {
|
||||
readonly updateMembership: {
|
||||
readonly membership: {
|
||||
readonly id: string;
|
||||
readonly role: MembershipRole;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type MembersSettingsTab_UpdateMembershipMutation = {
|
||||
response: MembersSettingsTab_UpdateMembershipMutation$data;
|
||||
variables: MembersSettingsTab_UpdateMembershipMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "UpdateMembershipPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "updateMembership",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Membership",
|
||||
"kind": "LinkedField",
|
||||
"name": "membership",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "role",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MembersSettingsTab_UpdateMembershipMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "MembersSettingsTab_UpdateMembershipMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "675437becfdd9cc5ea20d2b40b9ace37",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MembersSettingsTab_UpdateMembershipMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation MembersSettingsTab_UpdateMembershipMutation(\n $input: UpdateMembershipInput!\n) {\n updateMembership(input: $input) {\n membership {\n id\n role\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "29ead2b06842cc8ed98bbea1cf6c1bed";
|
||||
|
||||
export default node;
|
||||
@@ -34,6 +34,8 @@ import type { NodeOf } from "/types";
|
||||
import SnapshotFormDialog from "./dialog/SnapshotFormDialog";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<SnapshotGraphListQuery>;
|
||||
@@ -71,6 +73,8 @@ export default function SnapshotsPage(props: Props) {
|
||||
const snapshots = data.snapshots.edges.map((edge) => edge.node);
|
||||
usePageTitle(__("Snapshots"));
|
||||
|
||||
const hasAnyAction = isAuthorized(organizationId, "Snapshot", "deleteSnapshot");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -79,11 +83,13 @@ export default function SnapshotsPage(props: Props) {
|
||||
"Snapshots capture point-in-time views of your organization's compliance state. Create snapshots to track progress over time."
|
||||
)}
|
||||
>
|
||||
<SnapshotFormDialog connection={connectionId}>
|
||||
<Button variant="primary" icon={IconPlusLarge}>
|
||||
{__("New snapshot")}
|
||||
</Button>
|
||||
</SnapshotFormDialog>
|
||||
<Authorized entity="Organization" action="createSnapshot">
|
||||
<SnapshotFormDialog connection={connectionId}>
|
||||
<Button variant="primary" icon={IconPlusLarge}>
|
||||
{__("New snapshot")}
|
||||
</Button>
|
||||
</SnapshotFormDialog>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
|
||||
{snapshots.length > 0 ? (
|
||||
@@ -94,7 +100,7 @@ export default function SnapshotsPage(props: Props) {
|
||||
<Th>{__("Type")}</Th>
|
||||
<Th>{__("Description")}</Th>
|
||||
<Th>{__("Created")}</Th>
|
||||
<Th></Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -104,6 +110,7 @@ export default function SnapshotsPage(props: Props) {
|
||||
snapshot={snapshot}
|
||||
connectionId={connectionId}
|
||||
organizationId={organizationId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -126,6 +133,7 @@ type SnapshotRowProps = {
|
||||
snapshot: NodeOf<SnapshotsPageFragment$data["snapshots"]>;
|
||||
connectionId: string;
|
||||
organizationId: string;
|
||||
hasAnyAction: boolean;
|
||||
};
|
||||
|
||||
function SnapshotRow(props: SnapshotRowProps) {
|
||||
@@ -148,17 +156,21 @@ function SnapshotRow(props: SnapshotRowProps) {
|
||||
<Td className="text-txt-tertiary">
|
||||
{formatDate(props.snapshot.createdAt)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
onClick={deleteSnapshot}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
{props.hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<Authorized entity="Snapshot" action="deleteSnapshot">
|
||||
<DropdownItem
|
||||
onClick={deleteSnapshot}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tasksQuery } from "/hooks/graph/TaskGraph";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import TasksCard from "/components/tasks/TasksCard";
|
||||
import TaskFormDialog from "/components/tasks/TaskFormDialog";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const tasksFragment = graphql`
|
||||
fragment TasksPageFragment on Organization
|
||||
@@ -76,9 +77,11 @@ export default function TasksPage({ queryRef }: Props) {
|
||||
"Track your assigned compliance tasks and keep progress on track."
|
||||
)}
|
||||
>
|
||||
<TaskFormDialog connection={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("New task")}</Button>
|
||||
</TaskFormDialog>
|
||||
<Authorized entity="Organization" action="createTask">
|
||||
<TaskFormDialog connection={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("New task")}</Button>
|
||||
</TaskFormDialog>
|
||||
</Authorized>
|
||||
</PageHeader>
|
||||
<TasksCard connectionId={connectionId} tasks={tasks ?? []} />
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "/hooks/graph/TrustCenterAccessGraph";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type ContextType = {
|
||||
organization: {
|
||||
@@ -422,12 +423,14 @@ export default function TrustCenterAccessTab() {
|
||||
</p>
|
||||
</div>
|
||||
{organization.trustCenter?.id && (
|
||||
<Button icon={IconPlusLarge} onClick={() => {
|
||||
inviteForm.reset();
|
||||
dialogRef.current?.open();
|
||||
}}>
|
||||
{__("Add Access")}
|
||||
</Button>
|
||||
<Authorized entity="TrustCenter" action="createTrustCenterAccess">
|
||||
<Button icon={IconPlusLarge} onClick={() => {
|
||||
inviteForm.reset();
|
||||
dialogRef.current?.open();
|
||||
}}>
|
||||
{__("Add Access")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -512,18 +515,22 @@ export default function TrustCenterAccessTab() {
|
||||
className="flex gap-2 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleEditAccess(access)}
|
||||
disabled={isUpdating}
|
||||
icon={IconPencil}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(access.id)}
|
||||
disabled={isDeleting}
|
||||
icon={IconTrashCan}
|
||||
/>
|
||||
<Authorized entity="TrustCenterAccess" action="updateTrustCenterAccess">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleEditAccess(access)}
|
||||
disabled={isUpdating}
|
||||
icon={IconPencil}
|
||||
/>
|
||||
</Authorized>
|
||||
<Authorized entity="TrustCenterAccess" action="deleteTrustCenterAccess">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(access.id)}
|
||||
disabled={isDeleting}
|
||||
icon={IconTrashCan}
|
||||
/>
|
||||
</Authorized>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "/hooks/graph/TrustCenterFileGraph";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { TrustCenterFilesCard } from "/components/trustCenter/TrustCenterFilesCard";
|
||||
import { Authorized } from "/permissions";
|
||||
import type { TrustCenterFilesCardFragment$key } from "/components/trustCenter/__generated__/TrustCenterFilesCardFragment.graphql";
|
||||
|
||||
type ContextType = {
|
||||
@@ -200,9 +201,11 @@ export default function TrustCenterFilesTab() {
|
||||
{__("Upload and manage files for your trust center")}
|
||||
</p>
|
||||
</div>
|
||||
<Button icon={IconPlusLarge} onClick={() => createDialogRef.current?.open()}>
|
||||
{__("Add File")}
|
||||
</Button>
|
||||
<Authorized entity="Organization" action="createTrustCenterFile">
|
||||
<Button icon={IconPlusLarge} onClick={() => createDialogRef.current?.open()}>
|
||||
{__("Add File")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
{(isUpdating || isDeleting) && (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useUpdateTrustCenterMutation, useUploadTrustCenterNDAMutation, useDelet
|
||||
import type { TrustCenterGraphQuery$data } from "/hooks/graph/__generated__/TrustCenterGraphQuery.graphql";
|
||||
import { useState } from "react";
|
||||
import { SlackConnections } from "../../../components/organizations/SlackConnection";
|
||||
import { isAuthorized } from "/permissions";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
type ContextType = {
|
||||
organization: TrustCenterGraphQuery$data["organization"];
|
||||
@@ -22,12 +24,15 @@ export default function TrustCenterOverviewTab() {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const { organization } = useOutletContext<ContextType>();
|
||||
const { organizationId } = useParams();
|
||||
|
||||
const [updateTrustCenter, isUpdating] = useUpdateTrustCenterMutation();
|
||||
const [uploadNDA, isUploadingNDA] = useUploadTrustCenterNDAMutation();
|
||||
const [deleteNDA, isDeletingNDA] = useDeleteTrustCenterNDAMutation();
|
||||
const [isActive, setIsActive] = useState(organization.trustCenter?.active || false);
|
||||
|
||||
const canUpdateTrustCenter = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
|
||||
|
||||
const handleToggleActive = async (active: boolean) => {
|
||||
if (!organization.trustCenter?.id) {
|
||||
toast({
|
||||
@@ -128,6 +133,7 @@ export default function TrustCenterOverviewTab() {
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
onChange={handleToggleActive}
|
||||
disabled={!canUpdateTrustCenter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -210,25 +216,35 @@ export default function TrustCenterOverviewTab() {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleNDADelete}
|
||||
disabled={isDeletingNDA}
|
||||
/>
|
||||
{canUpdateTrustCenter && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleNDADelete}
|
||||
disabled={isDeletingNDA}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Dropzone
|
||||
description={__("Upload PDF files up to 10MB")}
|
||||
isUploading={isUploadingNDA}
|
||||
onDrop={handleNDAUpload}
|
||||
accept={{
|
||||
"application/pdf": [".pdf"],
|
||||
}}
|
||||
maxSize={10}
|
||||
/>
|
||||
<>
|
||||
{canUpdateTrustCenter ? (
|
||||
<Dropzone
|
||||
description={__("Upload PDF files up to 10MB")}
|
||||
isUploading={isUploadingNDA}
|
||||
onDrop={handleNDAUpload}
|
||||
accept={{
|
||||
"application/pdf": [".pdf"],
|
||||
}}
|
||||
maxSize={10}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("No NDA file uploaded")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
|
||||
import { complianceReportsFragment } from "./tabs/VendorComplianceTab";
|
||||
import type { VendorComplianceTabFragment$key } from "./tabs/__generated__/VendorComplianceTabFragment.graphql";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<VendorGraphNodeQuery>;
|
||||
@@ -97,15 +98,17 @@ export default function VendorDetailPage(props: Props) {
|
||||
{__("Assessment From Website")}
|
||||
</Button>
|
||||
</ImportAssessmentDialog>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteVendor}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
<Authorized entity="Vendor" action="deleteVendor">
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteVendor}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,8 @@ import type {
|
||||
} from "/hooks/graph/__generated__/VendorGraphPaginatedFragment.graphql";
|
||||
import { SortableTable, SortableTh } from "/components/SortableTable";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import { Authorized } from "/permissions";
|
||||
import { isAuthorized } from "/permissions";
|
||||
|
||||
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
|
||||
|
||||
@@ -61,6 +63,11 @@ export default function VendorsPage(props: Props) {
|
||||
|
||||
usePageTitle(__("Vendors"));
|
||||
|
||||
const hasAnyAction = !isSnapshotMode && (
|
||||
isAuthorized(organizationId, "Vendor", "updateVendor") ||
|
||||
isAuthorized(organizationId, "Vendor", "deleteVendor")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
@@ -71,12 +78,14 @@ export default function VendorsPage(props: Props) {
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode && (
|
||||
<CreateVendorDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
|
||||
</CreateVendorDialog>
|
||||
<Authorized entity="Organization" action="createVendor">
|
||||
<CreateVendorDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
|
||||
</CreateVendorDialog>
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
<SortableTable {...pagination}>
|
||||
@@ -86,7 +95,7 @@ export default function VendorsPage(props: Props) {
|
||||
<Th>{__("Accessed At")}</Th>
|
||||
<Th>{__("Data Risk")}</Th>
|
||||
<Th>{__("Business Risk")}</Th>
|
||||
<Th></Th>
|
||||
{hasAnyAction && <Th></Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -96,6 +105,7 @@ export default function VendorsPage(props: Props) {
|
||||
vendor={vendor}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
@@ -108,10 +118,12 @@ function VendorRow({
|
||||
vendor,
|
||||
organizationId,
|
||||
connectionId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
vendor: Vendor;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
@@ -144,19 +156,21 @@ function VendorRow({
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
{!isSnapshotMode && (
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
onClick={deleteVendor}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
<Authorized entity="Vendor" action="deleteVendor">
|
||||
<DropdownItem
|
||||
onClick={deleteVendor}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</Authorized>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</Td>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { useVendorFormFragment$key } from "/hooks/forms/__generated__/useVe
|
||||
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "./__generated__/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
|
||||
import type { VendorOverviewTabDataPrivacyAgreementFragment$key } from "./__generated__/VendorOverviewTabDataPrivacyAgreementFragment.graphql";
|
||||
import type { VendorCategory } from "@probo/vendors";
|
||||
import { Authorized } from "/permissions";
|
||||
|
||||
const vendorBusinessAssociateAgreementFragment = graphql`
|
||||
fragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {
|
||||
@@ -395,9 +396,11 @@ export default function VendorOverviewTab() {
|
||||
{/* Submit */}
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{__("Update vendor")}
|
||||
</Button>
|
||||
<Authorized entity="Vendor" action="updateVendor">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{__("Update vendor")}
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
80
apps/console/src/permissions/Authorized.tsx
Normal file
80
apps/console/src/permissions/Authorized.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { type ReactNode, useState, useEffect } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { isAuthorized } from "./permissions";
|
||||
|
||||
type Props = {
|
||||
entity: string;
|
||||
action: string;
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Conditionally render children based on authorization check
|
||||
* Automatically fetches permissions if not cached
|
||||
*
|
||||
* @param entity - The entity name (e.g., "Document", "Organization", "Vendor")
|
||||
* @param action - The action/field name (e.g., "get", "createDocument", "updateVendor")
|
||||
*
|
||||
* @example
|
||||
* <Authorized entity="Organization" action="createDocument">
|
||||
* <CreateButton />
|
||||
* </Authorized>
|
||||
*
|
||||
* @example
|
||||
* <Authorized entity="Document" action="get">
|
||||
* <DocumentViewer />
|
||||
* </Authorized>
|
||||
*
|
||||
* @example
|
||||
* <Authorized entity="Vendor" action="updateVendor">
|
||||
* <EditButton />
|
||||
* </Authorized>
|
||||
*/
|
||||
export function Authorized({
|
||||
entity,
|
||||
action,
|
||||
children,
|
||||
fallback = null,
|
||||
}: Props) {
|
||||
const { organizationId } = useParams();
|
||||
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setHasAccess(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to check authorization, catching promise throws
|
||||
try {
|
||||
const authorized = isAuthorized(organizationId, entity, action);
|
||||
setHasAccess(authorized);
|
||||
} catch (promise) {
|
||||
// If a promise is thrown (Suspense pattern), wait for it
|
||||
if (promise instanceof Promise) {
|
||||
promise
|
||||
.then(() => {
|
||||
// Permissions loaded, try again
|
||||
try {
|
||||
const authorized = isAuthorized(organizationId, entity, action);
|
||||
setHasAccess(authorized);
|
||||
} catch {
|
||||
setHasAccess(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setHasAccess(false);
|
||||
});
|
||||
} else {
|
||||
setHasAccess(false);
|
||||
}
|
||||
}
|
||||
}, [organizationId, entity, action]);
|
||||
|
||||
if (!organizationId || hasAccess === null || hasAccess === false) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
3
apps/console/src/permissions/index.ts
Normal file
3
apps/console/src/permissions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { isAuthorized, getUserRole, getAssignableRoles } from "./permissions";
|
||||
export { Authorized } from "./Authorized";
|
||||
export type { EntityPermissions } from "./permissions";
|
||||
131
apps/console/src/permissions/permissions.ts
Normal file
131
apps/console/src/permissions/permissions.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Authorization system that checks permissions from the backend
|
||||
// Permissions are fetched per organization from /authz/:organizationId/permissions
|
||||
// Format: { permissions: { "Document": { "node": true, "updateDocument": true }, "Organization": { "createDocument": true } }, role: "ADMIN" }
|
||||
|
||||
export type EntityPermissions = Record<string, Record<string, boolean>>;
|
||||
|
||||
type PermissionsResponse = {
|
||||
permissions: EntityPermissions;
|
||||
role: string;
|
||||
};
|
||||
|
||||
let cachedPermissions: EntityPermissions | null = null;
|
||||
let cachedRole: string | null = null;
|
||||
let cachePromise: Promise<PermissionsResponse> | null = null;
|
||||
let currentOrganizationId: string | null = null;
|
||||
|
||||
/**
|
||||
* Fetch permissions for the current user's role in the organization
|
||||
*/
|
||||
function fetchPermissions(organizationId: string): Promise<PermissionsResponse> {
|
||||
if (cachedPermissions && cachedRole && currentOrganizationId === organizationId) {
|
||||
return Promise.resolve({ permissions: cachedPermissions, role: cachedRole });
|
||||
}
|
||||
|
||||
if (cachePromise && currentOrganizationId === organizationId) {
|
||||
return cachePromise;
|
||||
}
|
||||
|
||||
if (currentOrganizationId !== organizationId) {
|
||||
cachedPermissions = null;
|
||||
cachedRole = null;
|
||||
cachePromise = null;
|
||||
currentOrganizationId = organizationId;
|
||||
}
|
||||
|
||||
const requestedOrgId = organizationId;
|
||||
|
||||
cachePromise = fetch(`/authz/${encodeURIComponent(organizationId)}/permissions`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch permissions: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data: PermissionsResponse) => {
|
||||
if (currentOrganizationId === requestedOrgId) {
|
||||
cachedPermissions = data.permissions;
|
||||
cachedRole = data.role;
|
||||
}
|
||||
cachePromise = null;
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
cachePromise = null;
|
||||
cachedPermissions = null;
|
||||
cachedRole = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return cachePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has permission for an entity and action
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @param entity - The entity name (e.g., "Document", "Organization", "Vendor")
|
||||
* @param action - The action/field name (e.g., "node", "updateDocument", "createDocument")
|
||||
* @returns true if the user has permission
|
||||
*
|
||||
* @example
|
||||
* isAuthorized(orgId, "Document", "get") // Check if user can query Document nodes
|
||||
* isAuthorized(orgId, "Document", "updateDocument") // Check if user can update documents
|
||||
* isAuthorized(orgId, "Organization", "createDocument") // Check if user can create documents
|
||||
*/
|
||||
export function isAuthorized(
|
||||
organizationId: string,
|
||||
entity: string,
|
||||
action: string
|
||||
): boolean {
|
||||
if (!cachedPermissions || currentOrganizationId !== organizationId) {
|
||||
throw fetchPermissions(organizationId);
|
||||
}
|
||||
|
||||
const entityPermissions = cachedPermissions[entity];
|
||||
if (!entityPermissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entityPermissions[action] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's role in the organization
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @returns The user's role (e.g., "OWNER", "ADMIN", "VIEWER", "FULL")
|
||||
*
|
||||
* @example
|
||||
* getUserRole(orgId) // Returns "ADMIN"
|
||||
*/
|
||||
export function getUserRole(organizationId: string): string {
|
||||
if (!cachedRole || currentOrganizationId !== organizationId) {
|
||||
throw fetchPermissions(organizationId);
|
||||
}
|
||||
|
||||
return cachedRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles that the current user can assign
|
||||
* Based on the rule: OWNER and FULL can assign any role, ADMIN can assign ADMIN and VIEWER but not OWNER
|
||||
*
|
||||
* @param organizationId - The organization ID
|
||||
* @returns Array of roles that can be assigned
|
||||
*/
|
||||
export function getAssignableRoles(organizationId: string): string[] {
|
||||
const currentRole = getUserRole(organizationId);
|
||||
|
||||
if (currentRole === "OWNER" || currentRole === "FULL") {
|
||||
return ["OWNER", "ADMIN", "VIEWER"];
|
||||
}
|
||||
|
||||
if (currentRole === "ADMIN") {
|
||||
return ["ADMIN", "VIEWER"];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import type { PropsWithChildren } from "react";
|
||||
import { RelayEnvironmentProvider } from "react-relay";
|
||||
|
||||
export class UnAuthenticatedError extends Error {
|
||||
constructor() {
|
||||
super("UNAUTHENTICATED");
|
||||
constructor(message?: string) {
|
||||
super(message || "UNAUTHENTICATED");
|
||||
this.name = "UnAuthenticatedError";
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,19 @@ export class AuthenticationRequiredError extends Error {
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("UNAUTHORIZED");
|
||||
constructor(message?: string) {
|
||||
super(message || "UNAUTHORIZED");
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || "FORBIDDEN");
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEndpoint(path: string): string {
|
||||
const host = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -81,6 +88,9 @@ const hasAuthenticationRequiredError = (error: GraphQLError) =>
|
||||
const hasUnauthorizedError = (error: GraphQLError) =>
|
||||
error.extensions?.code == "UNAUTHORIZED";
|
||||
|
||||
const hasForbiddenError = (error: GraphQLError) =>
|
||||
error.extensions?.code == "FORBIDDEN";
|
||||
|
||||
const fetchRelay: FetchFunction = async (
|
||||
request,
|
||||
variables,
|
||||
@@ -147,8 +157,9 @@ const fetchRelay: FetchFunction = async (
|
||||
if (json.errors) {
|
||||
const errors = json.errors as GraphQLError[];
|
||||
|
||||
if (errors.find(hasUnauthenticatedError)) {
|
||||
throw new UnAuthenticatedError();
|
||||
const unauthenticatedError = errors.find(hasUnauthenticatedError);
|
||||
if (unauthenticatedError) {
|
||||
throw new UnAuthenticatedError(unauthenticatedError.message);
|
||||
}
|
||||
|
||||
const authRequiredError = errors.find(hasAuthenticationRequiredError);
|
||||
@@ -163,8 +174,14 @@ const fetchRelay: FetchFunction = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.find(hasUnauthorizedError)) {
|
||||
throw new UnauthorizedError();
|
||||
const unauthorizedError = errors.find(hasUnauthorizedError);
|
||||
if (unauthorizedError) {
|
||||
throw new UnauthorizedError(unauthorizedError.message);
|
||||
}
|
||||
|
||||
const forbiddenError = errors.find(hasForbiddenError);
|
||||
if (forbiddenError) {
|
||||
throw new ForbiddenError(forbiddenError.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
relayEnvironment,
|
||||
UnAuthenticatedError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
} from "./providers/RelayProviders";
|
||||
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
|
||||
import { loadQuery, type PreloadedQuery } from "react-relay";
|
||||
@@ -59,6 +60,10 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
|
||||
return <PageError error="UNAUTHORIZED" />;
|
||||
}
|
||||
|
||||
if (error instanceof ForbiddenError) {
|
||||
return <PageError error="FORBIDDEN" />;
|
||||
}
|
||||
|
||||
return <PageError error={error?.toString()} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
||||
"/pages": fileURLToPath(new URL("./src/pages", import.meta.url)),
|
||||
"/routes": fileURLToPath(new URL("./src/routes", import.meta.url)),
|
||||
"/providers": fileURLToPath(new URL("./src/providers", import.meta.url)),
|
||||
"/permissions": fileURLToPath(new URL("./src/permissions", import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user