Merge pull request #2821 from Infisical/secret-approval-filterable-selects

Improvement: Secret Approval Form Filterable Selects
This commit is contained in:
Scott Wilson
2024-12-02 10:37:16 -08:00
committed by GitHub
6 changed files with 346 additions and 463 deletions
@@ -40,18 +40,24 @@ export const FilterableSelect = <T,>({
...props.components ...props.components
}} }}
classNames={{ classNames={{
container: () => "w-full font-inter", container: ({ isDisabled }) =>
control: ({ isFocused }) => twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
control: ({ isFocused, isDisabled }) =>
twMerge( twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400", isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer" `border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
} `
), ),
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5", placeholder: () =>
input: () => "pl-1 py-0.5", `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1",
valueContainer: () => valueContainer: () =>
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`, `px-1 max-h-[8.2rem] ${
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
} gap-1`,
singleValue: () => "leading-7 ml-1", singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5", multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm", multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400", multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1", indicatorsContainer: () => "p-1 gap-1",
@@ -60,7 +66,7 @@ export const FilterableSelect = <T,>({
dropdownIndicator: () => "text-bunker-200 p-1", dropdownIndicator: () => "text-bunker-200 p-1",
menuList: () => "flex flex-col gap-1", menuList: () => "flex flex-col gap-1",
menu: () => menu: () =>
"mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", "my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm", groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) => option: ({ isFocused, isSelected }) =>
twMerge( twMerge(
+12
View File
@@ -0,0 +1,12 @@
import { TWorkspaceUser } from "@app/hooks/api/users/types";
export const getMemberLabel = (member: TWorkspaceUser) => {
const {
inviteEmail,
user: { firstName, lastName, username, email }
} = member;
return firstName || lastName
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
: username || email || inviteEmail;
};
@@ -175,7 +175,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create policy Create Policy
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
@@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Name</Th> <Th>Name</Th>
<Th>Environment</Th> <Th>Environment</Th>
<Th>Secret Path</Th> <Th>Secret Path</Th>
<Th>Eligible Approvers</Th> <Th className="w-[18%]">Eligible Approvers</Th>
<Th>Eligible Group Approvers</Th> <Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th> <Th>Approval Required</Th>
<Th> <Th>
<DropdownMenu> <DropdownMenu>
@@ -256,9 +256,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
{!!currentWorkspace && {!!currentWorkspace &&
filteredPolicies?.map((policy) => ( filteredPolicies?.map((policy) => (
<ApprovalPolicyRow <ApprovalPolicyRow
projectSlug={currentWorkspace.slug}
policy={policy} policy={policy}
workspaceId={workspaceId}
key={policy.id} key={policy.id}
members={members} members={members}
groups={groups} groups={groups}
@@ -1,18 +1,12 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
DropdownMenu, FilterableSelect,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl, FormControl,
Input, Input,
Modal, Modal,
@@ -21,6 +15,7 @@ import {
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { import {
useCreateSecretApprovalPolicy, useCreateSecretApprovalPolicy,
@@ -46,21 +41,34 @@ type Props = {
const formSchema = z const formSchema = z
.object({ .object({
environment: z.string(), environment: z.object({ slug: z.string(), name: z.string() }),
name: z.string().optional(), name: z.string().optional(),
secretPath: z.string().optional(), secretPath: z.string().optional(),
approvals: z.number().min(1), approvals: z.number().min(1),
approvers: z userApprovers: z
.object({ type: z.nativeEnum(ApproverType), id: z.string() }) .object({ type: z.literal(ApproverType.User), id: z.string() })
.array()
.default([]),
groupApprovers: z
.object({ type: z.literal(ApproverType.Group), id: z.string() })
.array() .array()
.min(1)
.default([]), .default([]),
policyType: z.nativeEnum(PolicyType), policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel) enforcementLevel: z.nativeEnum(EnforcementLevel)
}) })
.refine((data) => data.approvers, { .superRefine((data, ctx) => {
path: ["approvers"], if (!(data.groupApprovers.length || data.userApprovers.length)) {
message: "At least one approver should be provided." ctx.addIssue({
path: ["userApprovers"],
code: z.ZodIssueCode.custom,
message: "At least one approver should be provided"
});
ctx.addIssue({
path: ["groupApprovers"],
code: z.ZodIssueCode.custom,
message: "At least one approver should be provided"
});
}
}); });
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
@@ -84,8 +92,15 @@ export const AccessPolicyForm = ({
values: editValues values: editValues
? { ? {
...editValues, ...editValues,
environment: editValues.environment.slug, environment: editValues.environment,
approvers: editValues?.approvers || [], userApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
groupApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
approvals: editValues?.approvals approvals: editValues?.approvals
} }
: undefined : undefined
@@ -110,18 +125,27 @@ export const AccessPolicyForm = ({
const approversRequired = watch("approvals") || 1; const approversRequired = watch("approvals") || 1;
const handleCreatePolicy = async (data: TFormSchema) => { const handleCreatePolicy = async ({
environment,
groupApprovers,
userApprovers,
...data
}: TFormSchema) => {
if (!projectId) return; if (!projectId) return;
try { try {
if (data.policyType === PolicyType.ChangePolicy) { if (data.policyType === PolicyType.ChangePolicy) {
await createSecretApprovalPolicy({ await createSecretApprovalPolicy({
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
workspaceId: currentWorkspace?.id || "" workspaceId: currentWorkspace?.id || ""
}); });
} else { } else {
await createAccessApprovalPolicy({ await createAccessApprovalPolicy({
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
projectSlug projectSlug
}); });
} }
@@ -139,7 +163,12 @@ export const AccessPolicyForm = ({
} }
}; };
const handleUpdatePolicy = async (data: TFormSchema) => { const handleUpdatePolicy = async ({
environment,
userApprovers,
groupApprovers,
...data
}: TFormSchema) => {
if (!projectId || !projectSlug) return; if (!projectId || !projectSlug) return;
if (!editValues?.id) return; if (!editValues?.id) return;
@@ -148,12 +177,15 @@ export const AccessPolicyForm = ({
await updateSecretApprovalPolicy({ await updateSecretApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
workspaceId: currentWorkspace?.id || "" workspaceId: currentWorkspace?.id || ""
}); });
} else { } else {
await updateAccessApprovalPolicy({ await updateAccessApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
projectSlug projectSlug
}); });
} }
@@ -179,150 +211,178 @@ export const AccessPolicyForm = ({
} }
}; };
const memberOptions = useMemo(
() =>
members.map((member) => ({
id: member.user.id,
type: ApproverType.User
})),
[members]
);
const groupOptions = useMemo(
() =>
groups?.map(({ group }) => ({
id: group.id,
type: ApproverType.Group
})),
[groups]
);
return ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}> <ModalContent
className="max-w-2xl"
bodyClassName="overflow-visible"
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
>
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller <div className="grid grid-cols-2 gap-x-3">
control={control} <Controller
name="policyType" control={control}
defaultValue={PolicyType.ChangePolicy} name="policyType"
render={({ field: { value, onChange }, fieldState: { error } }) => ( defaultValue={PolicyType.ChangePolicy}
<FormControl render={({ field: { value, onChange }, fieldState: { error } }) => (
label="Policy Type" <FormControl
isRequired label="Policy Type"
isError={Boolean(error)} isRequired
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path." isError={Boolean(error)}
errorText={error?.message} tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
> 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) => { <Select
return ( isDisabled={isEditMode}
<SelectItem value={policyType} key={`policy-type-${policyType}`}> value={value}
{policyDetails[policyType].name} onValueChange={(val) => onChange(val as PolicyType)}
</SelectItem> className="w-full border border-mineshaft-500"
); >
})} {Object.values(PolicyType).map((policyType) => {
</Select> return (
</FormControl> <SelectItem value={policyType} key={`policy-type-${policyType}`}>
)} {policyDetails[policyType].name}
/> </SelectItem>
<Controller );
control={control} })}
name="name" </Select>
render={({ field, fieldState: { error } }) => ( </FormControl>
<FormControl )}
label="Policy Name" />
isError={Boolean(error)} <Controller
errorText={error?.message} control={control}
> name="approvals"
<Input {...field} value={field.value || ""} /> defaultValue={1}
</FormControl> render={({ field, fieldState: { error } }) => (
)} <FormControl
/> label="Minimum Approvals Required"
<Controller isError={Boolean(error)}
control={control} errorText={error?.message}
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) => ( <Input
<SelectItem {...field}
value={sourceEnvironment.slug} type="number"
key={`azure-key-vault-environment-${sourceEnvironment.slug}`} min={1}
> onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
{sourceEnvironment.name} />
</SelectItem> </FormControl>
))} )}
</Select> />
</FormControl> <Controller
)} control={control}
/> name="name"
<Controller render={({ field, fieldState: { error } }) => (
control={control} <FormControl
name="secretPath" label="Policy Name"
render={({ field, fieldState: { error } }) => ( isError={Boolean(error)}
<FormControl errorText={error?.message}
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</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}
tooltipText="Determines the level of enforcement for required approvers of a request"
helperText={
<div className="ml-1">
{field.value === EnforcementLevel.Hard
? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.`
: `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`}
</div>
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
> >
{Object.values(EnforcementLevel).map((level) => { <Input {...field} value={field.value || ""} />
return ( </FormControl>
<SelectItem value={level} key={`enforcement-level-${level}`}> )}
<span className="capitalize">{level}</span> />
</SelectItem> <Controller
); control={control}
})} name="enforcementLevel"
</Select> defaultValue={EnforcementLevel.Hard}
</FormControl> render={({ field, fieldState: { error } }) => (
)} <FormControl
/> label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
<>
<p>
Determines the level of enforcement for required approvers of a request:
</p>
<p className="mt-2">
<span className="font-bold">Hard</span> enforcement requires at least{" "}
<span className="font-bold"> {approversRequired}</span> approver(s) to
approve the request.`
</p>
<p className="mt-2">
<span className="font-bold">Soft</span> enforcement At least{" "}
<span className="font-bold">{approversRequired}</span> approver(s) must
approve the request; however, the requester can bypass approval
requirements in emergencies.
</p>
</>
}
>
<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}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<div className="mb-2"> <div className="mb-2">
<p>Approvers</p> <p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90"> <p className="font-inter text-xs text-mineshaft-300 opacity-90">
@@ -331,127 +391,53 @@ export const AccessPolicyForm = ({
</div> </div>
<Controller <Controller
control={control} control={control}
name="approvers" name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="User Approvers" label="User Approvers"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<DropdownMenu> <FilterableSelect
<DropdownMenuTrigger asChild> menuPlacement="top"
<Input isMulti
isReadOnly placeholder="Select members that are allowed to approve requests..."
value={ options={memberOptions}
value?.filter((e) => e.type === ApproverType.User).length getOptionValue={(option) => option.id}
? `${value.filter((e) => e.type === ApproverType.User).length} selected` getOptionLabel={(option) => {
: "None" const member = members?.find((m) => m.user.id === option.id);
}
className="text-left" if (!member) return option.id;
/>
</DropdownMenuTrigger> return getMemberLabel(member);
<DropdownMenuContent }}
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }} value={value}
align="start" onChange={onChange}
> />
<DropdownMenuLabel>
Select members that are allowed to approve requests
</DropdownMenuLabel>
{members.map(({ user }) => {
const { id: userId } = user;
const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === userId && el.type === ApproverType.User
).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id !== userId && el.type !== ApproverType.User
)
: [...(value || []), { id: userId, type: ApproverType.User }]
);
}}
key={`create-policy-members-${userId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl> </FormControl>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="approvers" name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Group Approvers" label="Group Approvers"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<DropdownMenu> <FilterableSelect
<DropdownMenuTrigger asChild> menuPlacement="top"
<Input isMulti
isReadOnly placeholder="Select groups that are allowed to approve requests..."
value={ options={groupOptions}
value?.filter((e) => e.type === ApproverType.Group).length getOptionValue={(option) => option.id}
? `${ getOptionLabel={(option) =>
value?.filter((e) => e.type === ApproverType.Group).length groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
} selected` }
: "None" value={value}
} onChange={onChange}
className="text-left" />
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select groups that are allowed to approve requests
</DropdownMenuLabel>
{groups &&
groups.map(({ group }) => {
const { id } = group;
const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === id && el.type === ApproverType.Group
).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id !== id && el.type !== ApproverType.Group
)
: [...(value || []), { id, type: ApproverType.Group }]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl> </FormControl>
)} )}
/> />
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useMemo } from "react";
import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -8,19 +8,19 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
Input,
Td, Td,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import { Approver } from "@app/hooks/api/accessApproval/types";
import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types";
import { TGroupMembership } from "@app/hooks/api/groups/types"; import { TGroupMembership } from "@app/hooks/api/groups/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { ApproverType } from "@app/hooks/api/secretApproval/types";
import { WorkspaceEnv } from "@app/hooks/api/types"; import { WorkspaceEnv } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types"; import { TWorkspaceUser } from "@app/hooks/api/users/types";
@@ -35,14 +35,12 @@ interface IPolicy {
updatedAt: Date; updatedAt: Date;
policyType: PolicyType; policyType: PolicyType;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
}; }
type Props = { type Props = {
policy: IPolicy; policy: IPolicy;
members?: TWorkspaceUser[]; members?: TWorkspaceUser[];
groups?: TGroupMembership[]; groups?: TGroupMembership[];
projectSlug: string;
workspaceId: string;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}; };
@@ -51,175 +49,58 @@ export const ApprovalPolicyRow = ({
policy, policy,
members = [], members = [],
groups = [], groups = [],
projectSlug,
workspaceId,
onEdit, onEdit,
onDelete onDelete
}: Props) => { }: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []); const labels = useMemo(() => {
const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); const usersInPolicy = policy.approvers
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); ?.filter((approver) => approver.type === ApproverType.User)
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); .map((approver) => approver.id);
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
const { permission } = useProjectPermission(); const groupsInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map((approver) => approver.id);
const memberLabels = usersInPolicy?.length
? members
.filter((member) => usersInPolicy?.includes(member.user.id))
.map((member) => getMemberLabel(member))
.join(", ")
: null;
const groupLabels = groupsInPolicy?.length
? groups
.filter(({ group }) => groupsInPolicy?.includes(group.id))
.map(({ group }) => group.name)
.join(", ")
: null;
return {
members: memberLabels,
groups: groupLabels
};
}, [policy, members, groups]);
return ( return (
<Tr> <Tr>
<Td>{policy.name}</Td> <Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td> <Td className="max-w-0">
<DropdownMenu <Tooltip
onOpenChange={(isOpen) => { side="left"
if (!isOpen) { content={labels.members ?? "No users are assigned as approvers for this policy"}
if (policy.policyType === PolicyType.AccessPolicy) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
}
}
);
} else {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
}
}
);
}
} else {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
}
}}
> >
<DropdownMenuTrigger <p className="truncate">{labels.members ?? "-"}</p>
asChild </Tooltip>
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(({ user }) => {
const userId = user.id;
const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }]
);
}}
key={`create-policy-members-${userId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td> </Td>
<Td> <Td className="max-w-0">
<DropdownMenu <Tooltip
onOpenChange={(isOpen) => { side="left"
if (!isOpen) { content={labels.groups ?? "No groups are assigned as approvers for this policy"}
if (policy.policyType === PolicyType.AccessPolicy) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
}
},
);
} else {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
}
}
);
}
} else {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
}
}}
> >
<DropdownMenuTrigger asChild> <p className="truncate">{labels.groups ?? "-"}</p>
<Input </Tooltip>
isReadOnly
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select groups that are allowed to approve requests
</DropdownMenuLabel>
{groups && groups.map(({ group }) => {
const { id } = group;
const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedGroupApprovers(
isChecked
? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group)
: [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }]
);
}}
key={`create-policy-groups-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td> </Td>
<Td>{policy.approvals}</Td> <Td>{policy.approvals}</Td>
<Td> <Td>
@@ -229,12 +110,12 @@ export const ApprovalPolicyRow = ({
</Td> </Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer"> <DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex justify-center items-center hover:text-primary-400 data-[state=open]:text-primary-400 hover:scale-125 data-[state=open]:scale-125 transition-transform duration-300 ease-in-out"> <div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} /> <FontAwesomeIcon size="sm" icon={faEllipsis} />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="p-1 min-w-[100%]"> <DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
@@ -722,26 +722,6 @@ export const SecretOverviewPage = () => {
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleEnvSelect(envId);
}}
key={envId}
disabled={visibleEnvs?.length === 1}
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">{name}</div>
</DropdownMenuItem>
);
})}
{/* <DropdownMenuItem className="px-1.5" asChild> {/* <DropdownMenuItem className="px-1.5" asChild>
<Button <Button
size="xs" size="xs"
@@ -796,6 +776,26 @@ export const SecretOverviewPage = () => {
<span>Secrets</span> <span>Secrets</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleEnvSelect(envId);
}}
key={envId}
disabled={visibleEnvs?.length === 1}
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">{name}</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}