mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 08:27:36 +00:00
feat(frontend): welcome ApprovalPolicyList
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
import { PolicyType } from "@app/hooks/api/policies/enums";
|
||||||
|
|
||||||
|
export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
|
||||||
|
[PolicyType.AccessPolicy]: {
|
||||||
|
className: "bg-lime-900 text-lime-100",
|
||||||
|
name: "Access Policy"
|
||||||
|
},
|
||||||
|
[PolicyType.ChangePolicy]: {
|
||||||
|
className: "bg-indigo-900 text-indigo-100",
|
||||||
|
name: "Change Policy"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EnforcementLevel, PolicyType } from "../policies/enums";
|
||||||
import { TProjectPermission } from "../roles/types";
|
import { TProjectPermission } from "../roles/types";
|
||||||
import { WorkspaceEnv } from "../workspace/types";
|
import { WorkspaceEnv } from "../workspace/types";
|
||||||
|
|
||||||
@@ -11,6 +12,11 @@ export type TAccessApprovalPolicy = {
|
|||||||
environment: WorkspaceEnv;
|
environment: WorkspaceEnv;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
approvers: string[];
|
approvers: string[];
|
||||||
|
policyType: PolicyType;
|
||||||
|
approversRequired: boolean;
|
||||||
|
enforcementLevel: EnforcementLevel;
|
||||||
|
updatedAt: Date;
|
||||||
|
userApprovers?: { userId: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAccessApprovalRequest = {
|
export type TAccessApprovalRequest = {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export enum EnforcementLevel {
|
||||||
|
Hard = "hard",
|
||||||
|
Soft = "soft"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PolicyType {
|
||||||
|
ChangePolicy = "change",
|
||||||
|
AccessPolicy = "access"
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
import { Divider } from "@app/components/v2/Divider";
|
import { Divider } from "@app/components/v2/Divider";
|
||||||
import { useWorkspace } from "@app/context";
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api";
|
||||||
|
|
||||||
import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList";
|
import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList";
|
||||||
import { AccessApprovalRequest } from "./components/AccessApprovalRequest";
|
import { AccessApprovalRequest } from "./components/AccessApprovalRequest";
|
||||||
|
import { ApprovalPolicyList } from "./components/ApprovalPolicyList";
|
||||||
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
||||||
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
||||||
|
|
||||||
@@ -15,13 +18,19 @@ enum TabSection {
|
|||||||
SecretApprovalRequests = "approval-requests",
|
SecretApprovalRequests = "approval-requests",
|
||||||
SecretPolicies = "approval-rules",
|
SecretPolicies = "approval-rules",
|
||||||
ResourcePolicies = "resource-rules",
|
ResourcePolicies = "resource-rules",
|
||||||
ResourceApprovalRequests = "resource-requests"
|
ResourceApprovalRequests = "resource-requests",
|
||||||
|
Policies = "policies"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecretApprovalPage = () => {
|
export const SecretApprovalPage = () => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const projectId = currentWorkspace?.id || "";
|
const projectId = currentWorkspace?.id || "";
|
||||||
const projectSlug = currentWorkspace?.slug || "";
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
|
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId: projectId });
|
||||||
|
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
|
||||||
|
const defaultTab = (accessApprovalRequestCount?.pendingCount || 0) > (secretApprovalReqCount?.open || 0)
|
||||||
|
? TabSection.ResourceApprovalRequests
|
||||||
|
: TabSection.SecretApprovalRequests;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
||||||
@@ -45,13 +54,21 @@ export const SecretApprovalPage = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue={TabSection.SecretApprovalRequests}>
|
<Tabs defaultValue={defaultTab}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={TabSection.SecretApprovalRequests}>Secret Requests</Tab>
|
<Tab value={TabSection.SecretApprovalRequests}>
|
||||||
|
Secret Requests
|
||||||
|
{Boolean(secretApprovalReqCount?.open) && (<Badge className="ml-2">{secretApprovalReqCount?.open}</Badge>)}
|
||||||
|
</Tab>
|
||||||
<Tab value={TabSection.SecretPolicies}>Secret Policies</Tab>
|
<Tab value={TabSection.SecretPolicies}>Secret Policies</Tab>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Tab value={TabSection.ResourceApprovalRequests}>Access Requests</Tab>
|
<Tab value={TabSection.ResourceApprovalRequests}>
|
||||||
|
Access Requests
|
||||||
|
{Boolean(accessApprovalRequestCount?.pendingCount) && <Badge className="ml-2">{accessApprovalRequestCount?.pendingCount}</Badge>}
|
||||||
|
</Tab>
|
||||||
<Tab value={TabSection.ResourcePolicies}>Access Request Policies</Tab>
|
<Tab value={TabSection.ResourcePolicies}>Access Request Policies</Tab>
|
||||||
|
<Divider />
|
||||||
|
<Tab value={TabSection.Policies}>Policies</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={TabSection.SecretPolicies}>
|
<TabPanel value={TabSection.SecretPolicies}>
|
||||||
<SecretApprovalPolicyList workspaceId={projectId} />
|
<SecretApprovalPolicyList workspaceId={projectId} />
|
||||||
@@ -65,6 +82,9 @@ export const SecretApprovalPage = () => {
|
|||||||
<TabPanel value={TabSection.ResourcePolicies}>
|
<TabPanel value={TabSection.ResourcePolicies}>
|
||||||
<AccessApprovalPolicyList workspaceId={projectId} />
|
<AccessApprovalPolicyList workspaceId={projectId} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={TabSection.Policies}>
|
||||||
|
<ApprovalPolicyList workspaceId={projectId} />
|
||||||
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
import { useMemo,useState } from "react";
|
||||||
|
import { faCheckCircle,faChevronDown, faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr,
|
||||||
|
UpgradePlanModal
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
TProjectPermission,
|
||||||
|
useProjectPermission,
|
||||||
|
useSubscription,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, useGetSecretApprovalPolicies, useGetWorkspaceUsers } from "@app/hooks/api";
|
||||||
|
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
|
||||||
|
import { PolicyType } from "@app/hooks/api/policies/enums";
|
||||||
|
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
import { AccessPolicyForm } from "./components/AccessPolicyModal";
|
||||||
|
import { ApprovalPolicyRow } from "./components/ApprovalPolicyRow";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
|
||||||
|
const { data: accessPolicies, isLoading: isAccessPoliciesLoading } = useGetAccessApprovalPolicies({
|
||||||
|
projectSlug: currentWorkspace?.slug as string,
|
||||||
|
options: {
|
||||||
|
enabled:
|
||||||
|
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
|
||||||
|
!!currentWorkspace?.slug
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { data: secretPolicies, isLoading: isSecretPoliciesLoading } = useGetSecretApprovalPolicies({
|
||||||
|
workspaceId: currentWorkspace?.id as string,
|
||||||
|
options: {
|
||||||
|
enabled:
|
||||||
|
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
|
||||||
|
!!currentWorkspace?.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// merge data sorted by updatedAt
|
||||||
|
const policies = [
|
||||||
|
...(accessPolicies?.map(policy => ({ ...policy, policyType: PolicyType.AccessPolicy })) || []),
|
||||||
|
...(secretPolicies?.map(policy => ({ ...policy, policyType: PolicyType.ChangePolicy })) || [])
|
||||||
|
].sort((a, b) => {
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
policies,
|
||||||
|
isLoading: isAccessPoliciesLoading || isSecretPoliciesLoading
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||||
|
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
|
||||||
|
"policyForm",
|
||||||
|
"deletePolicy",
|
||||||
|
"upgradePlan"
|
||||||
|
] as const);
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||||
|
const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(permission, currentWorkspace);
|
||||||
|
|
||||||
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filteredPolicies = useMemo(() => {
|
||||||
|
return filterType
|
||||||
|
? policies.filter(policy => policy.policyType === filterType)
|
||||||
|
: policies;
|
||||||
|
}, [policies, filterType]);
|
||||||
|
|
||||||
|
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
|
||||||
|
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
|
||||||
|
|
||||||
|
const handleDeletePolicy = async () => {
|
||||||
|
const { id, policyType } = popUp.deletePolicy.data as TAccessApprovalPolicy;
|
||||||
|
if (!currentWorkspace?.slug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (policyType === PolicyType.ChangePolicy) {
|
||||||
|
await deleteSecretApprovalPolicy({
|
||||||
|
workspaceId,
|
||||||
|
id
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await deleteAccessApprovalPolicy({
|
||||||
|
projectSlug: currentWorkspace?.slug,
|
||||||
|
id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted policy"
|
||||||
|
});
|
||||||
|
handlePopUpClose("deletePolicy");
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete policy"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-end justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
|
||||||
|
<div className="mt-2 text-sm text-bunker-300">
|
||||||
|
Implement granular policies for access requests and secrets management.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Create}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription && !subscription?.secretApproval) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("policyForm");
|
||||||
|
}}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Create policy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Environment</Th>
|
||||||
|
<Th>Secret Path</Th>
|
||||||
|
<Th>Eligible Approvers</Th>
|
||||||
|
<Th>Approval Required</Th>
|
||||||
|
<Th>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="text-bunker-300"
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setFilterType(null)}
|
||||||
|
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setFilterType(PolicyType.AccessPolicy)}
|
||||||
|
icon={filterType === PolicyType.AccessPolicy && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Access Policy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setFilterType(PolicyType.ChangePolicy)}
|
||||||
|
icon={filterType === PolicyType.ChangePolicy && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Change Policy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Th>
|
||||||
|
<Th />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isPoliciesLoading && (
|
||||||
|
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
|
||||||
|
)}
|
||||||
|
{!isPoliciesLoading && !filteredPolicies?.length && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={6}>
|
||||||
|
<EmptyState title="No policies found" icon={faFileShield} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
{!!currentWorkspace &&
|
||||||
|
filteredPolicies?.map((policy) => (
|
||||||
|
<ApprovalPolicyRow
|
||||||
|
projectSlug={currentWorkspace.slug}
|
||||||
|
policy={policy}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
key={policy.id}
|
||||||
|
members={members}
|
||||||
|
onEdit={() => handlePopUpOpen("policyForm", policy)}
|
||||||
|
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<AccessPolicyForm
|
||||||
|
projectSlug={currentWorkspace?.slug!}
|
||||||
|
isOpen={popUp.policyForm.isOpen}
|
||||||
|
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
|
||||||
|
members={members}
|
||||||
|
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deletePolicy.isOpen}
|
||||||
|
deleteKey="remove"
|
||||||
|
title="Do you want to remove this policy?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||||
|
onDeleteApproved={handleDeletePolicy}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { policyDetails } from "@app/helpers/policies";
|
||||||
|
import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
|
||||||
|
import {
|
||||||
|
useCreateAccessApprovalPolicy,
|
||||||
|
useUpdateAccessApprovalPolicy
|
||||||
|
} from "@app/hooks/api/accessApproval";
|
||||||
|
import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types";
|
||||||
|
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
|
||||||
|
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle: (isOpen: boolean) => void;
|
||||||
|
members?: TWorkspaceUser[];
|
||||||
|
projectSlug: string;
|
||||||
|
editValues?: TAccessApprovalPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
environment: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
secretPath: z.string().optional(),
|
||||||
|
approvals: z.number().min(1),
|
||||||
|
approvers: z.string().array().min(1),
|
||||||
|
policyType: z.nativeEnum(PolicyType),
|
||||||
|
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
||||||
|
})
|
||||||
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
|
path: ["approvals"],
|
||||||
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const AccessPolicyForm = ({
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
members = [],
|
||||||
|
projectSlug,
|
||||||
|
editValues
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<TFormSchema>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
values: editValues ? {
|
||||||
|
...editValues,
|
||||||
|
environment: editValues.environment.slug,
|
||||||
|
approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const environments = currentWorkspace?.environments || [];
|
||||||
|
const isEditMode = Boolean(editValues);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !isEditMode) reset({});
|
||||||
|
}, [isOpen, isEditMode]);
|
||||||
|
|
||||||
|
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
|
||||||
|
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
|
||||||
|
|
||||||
|
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
|
||||||
|
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
|
||||||
|
|
||||||
|
const enforcementLevel = watch("enforcementLevel");
|
||||||
|
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
|
||||||
|
|
||||||
|
const handleCreatePolicy = async (data: TFormSchema) => {
|
||||||
|
if (!projectSlug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.policyType === PolicyType.ChangePolicy) {
|
||||||
|
await createSecretApprovalPolicy({
|
||||||
|
...data,
|
||||||
|
workspaceId: currentWorkspace?.id || ""
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createAccessApprovalPolicy({
|
||||||
|
...data,
|
||||||
|
projectSlug
|
||||||
|
});
|
||||||
|
}
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully created policy"
|
||||||
|
});
|
||||||
|
onToggle(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to create policy"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePolicy = async (data: TFormSchema) => {
|
||||||
|
if (!projectSlug) return;
|
||||||
|
if (!editValues?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.policyType === PolicyType.ChangePolicy) {
|
||||||
|
await updateSecretApprovalPolicy({
|
||||||
|
id: editValues?.id,
|
||||||
|
...data,
|
||||||
|
workspaceId: currentWorkspace?.id || ""
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await updateAccessApprovalPolicy({
|
||||||
|
id: editValues?.id,
|
||||||
|
...data,
|
||||||
|
projectSlug
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully updated policy"
|
||||||
|
});
|
||||||
|
onToggle(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "failed to update policy"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: TFormSchema) => {
|
||||||
|
if (isEditMode) {
|
||||||
|
await handleUpdatePolicy(data);
|
||||||
|
} else {
|
||||||
|
await handleCreatePolicy(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEnforcementLevel = (level: EnforcementLevel) => {
|
||||||
|
if (level === EnforcementLevel.Hard) return "Hard";
|
||||||
|
if (level === EnforcementLevel.Soft) return "Soft";
|
||||||
|
return level;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||||
|
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}>
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="policyType"
|
||||||
|
defaultValue={PolicyType.ChangePolicy}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Policy Type"
|
||||||
|
isRequired
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
isDisabled={isEditMode}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val as PolicyType)}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
{Object.values(PolicyType).map((policyType) => {
|
||||||
|
return (
|
||||||
|
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||||
|
{policyDetails[policyType].name}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="environment"
|
||||||
|
defaultValue={environments[0]?.slug}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment"
|
||||||
|
isRequired
|
||||||
|
className="mt-4"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
isDisabled={isEditMode}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val)}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
{environments.map((sourceEnvironment) => (
|
||||||
|
<SelectItem
|
||||||
|
value={sourceEnvironment.slug}
|
||||||
|
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
|
||||||
|
>
|
||||||
|
{sourceEnvironment.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="secretPath"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="approvers"
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Required Approvers"
|
||||||
|
isRequired
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Input
|
||||||
|
isReadOnly
|
||||||
|
value={value?.length ? `${value.length} selected` : "None"}
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Select members that are allowed to approve requests
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{members.map(({ id, user }) => {
|
||||||
|
const userId = watch("policyType") === PolicyType.ChangePolicy ? user.id : id;
|
||||||
|
const isChecked = value?.includes(userId);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
onChange(
|
||||||
|
isChecked ? value?.filter((el: string) => el !== userId) : [...(value || []), userId]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
key={`create-policy-members-${userId}`}
|
||||||
|
iconPos="right"
|
||||||
|
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="approvals"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Minimum Approvals Required"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="enforcementLevel"
|
||||||
|
defaultValue={EnforcementLevel.Hard}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Enforcement Level"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText={
|
||||||
|
field.value === EnforcementLevel.Hard
|
||||||
|
? "Hard enforcement prevents any request from being deployed without prior approval"
|
||||||
|
: "Soft enforcement lets requesters break glass to deploy without waiting for approval"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
{Object.values(EnforcementLevel).map((level) => {
|
||||||
|
return (
|
||||||
|
<SelectItem value={level} key={`enforcement-level-${level}`} className="text-xs">
|
||||||
|
{formatEnforcementLevel(level)}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{enforcementLevel === EnforcementLevel.Soft && (
|
||||||
|
<Alert hideTitle variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
Soft enforcement allows requesters to bypass approval, which may reduce system security and stability.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="mt-8 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onToggle(false)} variant="outline_bg">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Td,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||||
|
import { policyDetails } from "@app/helpers/policies";
|
||||||
|
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
|
||||||
|
import { PolicyType } from "@app/hooks/api/policies/enums";
|
||||||
|
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||||
|
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||||
|
|
||||||
|
interface IPolicy {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
environment: WorkspaceEnv;
|
||||||
|
projectId?: string;
|
||||||
|
secretPath?: string;
|
||||||
|
approvals: number;
|
||||||
|
approvers?: string[];
|
||||||
|
userApprovers?: { userId: string }[];
|
||||||
|
updatedAt: Date;
|
||||||
|
policyType: PolicyType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
policy: IPolicy;
|
||||||
|
members?: TWorkspaceUser[];
|
||||||
|
projectSlug: string;
|
||||||
|
workspaceId: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApprovalPolicyRow = ({
|
||||||
|
policy,
|
||||||
|
members = [],
|
||||||
|
projectSlug,
|
||||||
|
workspaceId,
|
||||||
|
onEdit,
|
||||||
|
onDelete
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedApprovers, setSelectedApprovers] = useState<string[]>(policy.userApprovers?.map(({ userId }) => userId) || policy.approvers || []);
|
||||||
|
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy();
|
||||||
|
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy();
|
||||||
|
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
|
||||||
|
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td>{policy.name}</Td>
|
||||||
|
<Td>{policy.environment.slug}</Td>
|
||||||
|
<Td>{policy.secretPath || "*"}</Td>
|
||||||
|
<Td>
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (policy.policyType === PolicyType.AccessPolicy) {
|
||||||
|
updateAccessApprovalPolicy(
|
||||||
|
{
|
||||||
|
projectSlug,
|
||||||
|
id: policy.id,
|
||||||
|
approvers: selectedApprovers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
// No changes needed here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateSecretApprovalPolicy(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
id: policy.id,
|
||||||
|
approvers: selectedApprovers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
// No changes needed here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedApprovers(policy.policyType === PolicyType.ChangePolicy
|
||||||
|
? policy?.userApprovers?.map(({ userId }) => userId) || []
|
||||||
|
: policy?.approvers || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
isReadOnly
|
||||||
|
value={selectedApprovers.length ? `${selectedApprovers.length} selected` : "None"}
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Select members that are allowed to approve changes
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{members?.map(({ id, user }) => {
|
||||||
|
const userId = policy.policyType === PolicyType.ChangePolicy ? user.id : id;
|
||||||
|
const isChecked = selectedApprovers.includes(userId);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setSelectedApprovers((state) =>
|
||||||
|
isChecked ? state.filter((el) => el !== userId) : [...state, userId]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
key={`create-policy-members-${userId}`}
|
||||||
|
iconPos="right"
|
||||||
|
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Td>
|
||||||
|
<Td>{policy.approvals}</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge className={policyDetails[policy.policyType].className}>
|
||||||
|
{policyDetails[policy.policyType].name}
|
||||||
|
</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex items-center justify-end space-x-4">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Edit"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit} isDisabled={!isAllowed}>
|
||||||
|
<FontAwesomeIcon icon={faPencil} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Delete"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
size="lg"
|
||||||
|
ariaLabel="edit"
|
||||||
|
onClick={onDelete}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ApprovalPolicyList } from "./ApprovalPolicyList";
|
||||||
Reference in New Issue
Block a user