Merge pull request #3850 from Infisical/policy-edit-revisions

improvement(project-policies): Revamp edit role page and access tree
This commit is contained in:
Scott Wilson
2025-06-26 07:46:35 -07:00
committed by GitHub
28 changed files with 682 additions and 1799 deletions
@@ -2,10 +2,9 @@ import { useCallback, useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import {
faAnglesUp,
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faUpRightAndDownLeftFromCenter,
faWindowRestore
faWindowRestore,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -23,8 +22,8 @@ import {
} from "@xyflow/react";
import { twMerge } from "tailwind-merge";
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { Button, IconButton, Select, SelectItem, Spinner, Tooltip } from "@app/components/v2";
import { ProjectPermissionSet, ProjectPermissionSub } from "@app/context/ProjectPermissionContext";
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
@@ -36,15 +35,17 @@ import { ViewMode } from "./types";
export type AccessTreeProps = {
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
subject: ProjectPermissionSub;
onClose: () => void;
};
const EdgeTypes = { base: BasePermissionEdge };
const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode };
const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
const AccessTreeContent = ({ permissions, subject, onClose }: AccessTreeProps) => {
const [selectedPath, setSelectedPath] = useState<string>("/");
const accessTreeData = useAccessTree(permissions, selectedPath);
const accessTreeData = useAccessTree(permissions, selectedPath, subject);
const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData;
const [initialRender, setInitialRender] = useState(true);
@@ -78,32 +79,32 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
useEffect(() => {
setInitialRender(true);
}, [selectedPath, environment]);
}, [selectedPath, environment, subject, viewMode]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (initialRender) {
timer = setTimeout(() => {
goToRootNode();
fitView({ duration: 500 });
setInitialRender(false);
}, 500);
}, 50);
}
return () => clearTimeout(timer);
}, [nodes, edges, getViewport(), initialRender, goToRootNode]);
}, [nodes, edges, getViewport(), initialRender, fitView]);
const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
const handleToggleUndockedView = () =>
setViewMode((prev) => (prev === ViewMode.Undocked ? ViewMode.Docked : ViewMode.Undocked));
const handleToggleView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Undocked : ViewMode.Modal));
const undockButtonLabel = `${viewMode === ViewMode.Undocked ? "Dock" : "Undock"} View`;
const windowButtonLabel = `${viewMode === ViewMode.Modal ? "Dock" : "Expand"} View`;
const expandButtonLabel = viewMode === ViewMode.Modal ? "Anchor View" : "Expand View";
const hideButtonLabel = "Hide Access Tree";
return (
<div
className={twMerge(
"w-full",
"mt-4 w-full",
viewMode === ViewMode.Modal && "fixed inset-0 z-50 p-10",
viewMode === ViewMode.Undocked &&
"fixed bottom-4 left-20 z-50 h-[40%] w-[38%] min-w-[32rem] lg:w-[34%]"
@@ -130,7 +131,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
type="submit"
className="h-10 rounded-r-none bg-mineshaft-700"
leftIcon={<FontAwesomeIcon icon={faWindowRestore} />}
onClick={handleToggleUndockedView}
onClick={handleToggleView}
>
Undock
</Button>
@@ -176,48 +177,62 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
<Spinner />
</Panel>
)}
{viewMode !== ViewMode.Undocked && (
<Panel position="top-left" className="flex gap-2">
<Select
value={environment}
onValueChange={accessTreeData.setEnvironment}
className="w-60"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Environment"
>
{Object.values(accessTreeData.environments).map((env) => (
<SelectItem
key={env.slug}
value={env.slug}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 truncate font-medium">{env.name}</div>
</SelectItem>
))}
</Select>
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
</Panel>
)}
{viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
{viewMode !== ViewMode.Undocked && (
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
)}
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<Panel position="top-right" className="flex gap-2">
<Tooltip position="bottom" align="center" content={expandButtonLabel}>
<IconButton
className="ml-1 w-10 rounded"
className="rounded p-2"
colorSchema="secondary"
variant="plain"
onClick={handleToggleUndockedView}
ariaLabel={undockButtonLabel}
onClick={handleToggleView}
ariaLabel={expandButtonLabel}
>
<FontAwesomeIcon
icon={
viewMode === ViewMode.Undocked
? faArrowUpRightFromSquare
? faUpRightAndDownLeftFromCenter
: faWindowRestore
}
/>
</IconButton>
</Tooltip>
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
<Tooltip align="end" position="bottom" content={hideButtonLabel}>
<IconButton
className="w-10 rounded"
className="rounded p-2"
colorSchema="secondary"
variant="plain"
onClick={handleToggleModalView}
ariaLabel={windowButtonLabel}
onClick={onClose}
ariaLabel={hideButtonLabel}
>
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faDownLeftAndUpRightToCenter
: faUpRightAndDownLeftFromCenter
}
/>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
</Panel>
@@ -253,6 +268,9 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
};
export const AccessTree = (props: AccessTreeProps) => {
const { subject } = props;
if (!subject) return null;
return (
<AccessTreeErrorBoundary {...props}>
<AccessTreeProvider>
@@ -29,7 +29,7 @@ export type AccessTreeForm = { metadata: { key: string; value: string }[] };
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
const [secretName, setSecretName] = useState("");
const formMethods = useForm<AccessTreeForm>({ defaultValues: { metadata: [] } });
const [viewMode, setViewMode] = useState(ViewMode.Docked);
const [viewMode, setViewMode] = useState(ViewMode.Modal);
const value = useMemo(
() => ({
@@ -33,7 +33,8 @@ type LevelFolderMap = Record<
export const useAccessTree = (
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>,
searchPath: string
searchPath: string,
subject: ProjectPermissionSub
) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
@@ -41,7 +42,6 @@ export const useAccessTree = (
const metadata = useWatch({ control, name: "metadata" });
const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]);
const [subject, setSubject] = useState(ProjectPermissionSub.Secrets);
const [environment, setEnvironment] = useState(currentWorkspace.environments[0]?.slug ?? "");
const { data: environmentsFolders, isPending } = useListProjectEnvironmentsFolders(
currentWorkspace.id
@@ -147,9 +147,7 @@ export const useAccessTree = (
const roleNode = createRoleNode({
subject,
environment: slug,
environments: environmentsFolders,
onSubjectChange: setSubject,
onEnvironmentChange: setEnvironment
environments: environmentsFolders
});
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
@@ -280,7 +278,6 @@ export const useAccessTree = (
subject,
environment,
setEnvironment,
setSubject,
isLoading: isPending,
environments: currentWorkspace.environments,
secretName,
@@ -81,7 +81,7 @@ export const AccessTreeSecretPathInput = ({
<FontAwesomeIcon icon={faSearch} />
</div>
) : (
<Tooltip position="bottom" content="Search paths">
<Tooltip position="bottom" content="Search Paths">
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
@@ -3,7 +3,6 @@ import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
@@ -29,7 +28,7 @@ const formatLabel = (text: string) => {
};
export const RoleNode = ({
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
data: { subject }
}: NodeProps & {
data: ReturnType<typeof createRoleNode>["data"] & {
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
@@ -44,61 +43,10 @@ export const RoleNode = ({
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex w-full flex-col items-center justify-center rounded-md border-2 border-mineshaft-500 bg-gradient-to-b from-mineshaft-700 to-mineshaft-800 px-5 py-4 font-inter shadow-2xl">
<div className="flex w-full min-w-[240px] flex-col gap-4">
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
<Select
value={subject}
onValueChange={(value) => onSubjectChange(value as ProjectPermissionSub)}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Subject"
>
{[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretImports
].map((sub) => {
return (
<SelectItem
className="relative flex items-center gap-2 py-2 pl-8 pr-8 text-sm capitalize hover:bg-mineshaft-700"
value={sub}
key={sub}
>
<div className="flex items-center gap-3">
{getSubjectIcon(sub)}
<span className="font-medium">{formatLabel(sub)}</span>
</div>
</SelectItem>
);
})}
</Select>
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Environment</div>
<Select
value={environment}
onValueChange={onEnvironmentChange}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Environment"
>
{Object.values(environments).map((env) => (
<SelectItem
key={env.slug}
value={env.slug}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{env.name}</div>
</SelectItem>
))}
</Select>
</div>
<div className="flex h-14 w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-2 py-3 font-inter shadow-lg transition-opacity duration-500">
<div className="flex items-center space-x-2 text-mineshaft-100">
{getSubjectIcon(subject)}
<span className="text-sm">{formatLabel(subject)} Access</span>
</div>
</div>
<Handle
@@ -1,5 +1,3 @@
import { Dispatch, SetStateAction } from "react";
import { ProjectPermissionSub } from "@app/context";
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
@@ -8,24 +6,18 @@ import { PermissionNode } from "../types";
export const createRoleNode = ({
subject,
environment,
environments,
onSubjectChange,
onEnvironmentChange
environments
}: {
subject: string;
subject: ProjectPermissionSub;
environment: string;
environments: TProjectEnvironmentsFolders;
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
onEnvironmentChange: (value: string) => void;
}) => ({
id: `role-${subject}-${environment}`,
position: { x: 0, y: 0 },
data: {
subject,
environment,
environments,
onSubjectChange,
onEnvironmentChange
environments
},
type: PermissionNode.Role,
height: 48,
@@ -39,16 +39,6 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => {
const positionedNodes = nodes.map((node) => {
const { x, y } = dagre.node(node.id);
if (node.type === "role") {
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - 150
}
};
}
return {
...node,
position: {
@@ -173,17 +173,19 @@ export const ProjectTemplateEditRoleForm = ({
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
<PermissionEmptyState />
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
<div>
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</div>
</FormProvider>
</form>
@@ -40,9 +40,9 @@ export const Checkbox = ({
<div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
<CheckboxPrimitive.Root
className={twMerge(
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500",
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400/50 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500",
isDisabled && "bg-bunker-400 hover:bg-bunker-400",
isChecked && "bg-primary hover:bg-primary",
isChecked && "border-primary/30 bg-primary/10",
Boolean(children) && "mr-3",
className
)}
@@ -53,7 +53,10 @@ export const Checkbox = ({
id={id}
>
<CheckboxPrimitive.Indicator
className={twMerge(`${checkIndicatorBg || "text-bunker-800"}`, indicatorClassName)}
className={twMerge(
`${checkIndicatorBg || "mt-[0.1rem] text-mineshaft-200"}`,
indicatorClassName
)}
>
{isIndeterminate ? (
<FontAwesomeIcon icon={faMinus} size="sm" />