Files
infisical/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx
T

310 lines
10 KiB
TypeScript

import { cloneElement, ReactNode, useState } from "react";
import { Control, Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import {
faChevronDown,
faChevronRight,
faDiagramProject,
faGripVertical,
faInfoCircle,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
import {
isConditionalSubjects,
TFormSchema,
TProjectPermissionObject
} from "./ProjectRoleModifySection.utils";
type Props<T extends ProjectPermissionSub> = {
title: string;
subject: T;
actions: TProjectPermissionObject[T]["actions"];
children?: JSX.Element;
isDisabled?: boolean;
onShowAccessTree?: (subject: ProjectPermissionSub) => void;
};
type ActionProps = {
value: string;
subject: ProjectPermissionSub;
rootIndex: number;
label: ReactNode;
isDisabled?: boolean;
control: Control<TFormSchema>;
};
const ActionCheckbox = ({ value, subject, isDisabled, rootIndex, label, control }: ActionProps) => {
// scott: using Controller caused discrepancy between field value and actual value, this is a hacky fix
const fieldValue = useWatch({
control,
name: `permissions.${subject}.${rootIndex}.${value}` as any
});
const { setValue } = useFormContext();
return (
<div className="flex items-center justify-center">
<Checkbox
isDisabled={isDisabled}
isChecked={Boolean(fieldValue)}
onCheckedChange={(isChecked) =>
setValue(`permissions.${subject}.${rootIndex}.${value}`, isChecked, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
})
}
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
>
{label}
</Checkbox>
</div>
);
};
export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchema["permissions"]>>({
subject,
actions,
children,
title,
isDisabled,
onShowAccessTree
}: Props<T>) => {
const { control, watch } = useFormContext<TFormSchema>();
const { fields, remove, insert, move } = useFieldArray({
control,
name: `permissions.${subject}`
});
// scott: this is a hacky work-around to resolve bug of fields not updating UI when removed
const watchFields = useWatch<TFormSchema>({
control,
name: `permissions.${subject}`
});
const [isOpen, setIsOpen] = useToggle();
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
if (!watchFields || !Array.isArray(watchFields) || watchFields.length === 0) return null;
const handleDragStart = (_: React.DragEvent, index: number) => {
setDraggedItem(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverItem(index);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) {
setDraggedItem(null);
setDragOverItem(null);
return;
}
move(draggedItem, dragOverItem);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
return (
<div className="overflow-clip border border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md">
<div
className="flex h-14 cursor-pointer items-center px-5 py-4 text-sm text-gray-300"
role="button"
tabIndex={0}
onClick={() => setIsOpen.toggle()}
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsOpen.toggle();
}
}}
>
<FontAwesomeIcon className="mr-8" icon={isOpen ? faChevronDown : faChevronRight} />
<div className="flex-grow text-base">{title}</div>
{fields.length > 1 && (
<div>
<Tag size="xs" className="mr-2 px-2">
{fields.length} Rules
</Tag>
</div>
)}
{isOpen && onShowAccessTree && (
<Button
leftIcon={<FontAwesomeIcon icon={faDiagramProject} />}
variant="outline_bg"
size="xs"
className="ml-2"
onClick={(e) => {
e.stopPropagation();
onShowAccessTree(subject);
}}
>
Visualize Access
</Button>
)}
{!isDisabled && isOpen && isConditionalSubjects(subject) && (
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="outline_bg"
className="ml-2"
size="xs"
onClick={(e) => {
e.stopPropagation();
insert(fields.length, [
{ read: false, edit: false, create: false, delete: false } as any
]);
}}
isDisabled={isDisabled}
>
Add Rule
</Button>
)}
</div>
{isOpen && (
<div key={`select-${subject}-type`} className="flex flex-col space-y-3 bg-bunker-700 p-3">
{fields.map((el, rootIndex) => {
let isFullReadAccessEnabled = false;
if (subject === ProjectPermissionSub.Secrets) {
isFullReadAccessEnabled = watch(`permissions.${subject}.${rootIndex}.read` as any);
}
const isInverted = watch(`permissions.${subject}.${rootIndex}.inverted` as any);
return (
<div
key={el.id}
className={twMerge(
"relative rounded-md border-l-[6px] bg-mineshaft-800 px-5 py-4 transition-colors duration-300",
isInverted ? "border-l-red-600/50" : "border-l-green-600/50",
dragOverItem === rootIndex ? "border-2 border-primary/50" : "",
draggedItem === rootIndex ? "opacity-50" : ""
)}
onDragOver={(e) => handleDragOver(e, rootIndex)}
onDrop={handleDrop}
>
{isConditionalSubjects(subject) && (
<div className="mb-4 flex items-center gap-3">
<div className="flex w-full items-center text-gray-300">
<div className="mr-3">Permission</div>
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-40"
className="w-full"
isDisabled={isDisabled}
position="popper"
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="ml-2 text-bunker-400"
/>
</Tooltip>
{!isDisabled && (
<Button
leftIcon={<FontAwesomeIcon icon={faTrash} />}
variant="outline_bg"
size="xs"
className="ml-auto mr-3"
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove Rule
</Button>
)}
{!isDisabled && (
<Tooltip position="left" content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, rootIndex)}
onDragEnd={handleDragEnd}
className="cursor-move text-bunker-300 hover:text-bunker-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
)}
</div>
</div>
)}
<div className="flex flex-col text-gray-300">
<div className="mb-2">Actions</div>
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">
{actions.map(({ label, value }, index) => {
if (typeof value !== "string") return undefined;
if (
subject === ProjectPermissionSub.Secrets &&
value === "read" &&
!isFullReadAccessEnabled
) {
return null;
}
return (
<ActionCheckbox
key={`${el.id}-${index + 1}`}
value={value}
label={label}
rootIndex={rootIndex}
control={control}
subject={subject}
isDisabled={isDisabled}
/>
);
})}
</div>
</div>
{children &&
cloneElement(children, {
position: rootIndex
})}
</div>
);
})}
</div>
)}
</div>
);
};