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,11 +211,34 @@ 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)}>
<div className="grid grid-cols-2 gap-x-3">
<Controller <Controller
control={control} control={control}
name="policyType" name="policyType"
@@ -213,63 +268,6 @@ export const AccessPolicyForm = ({
</FormControl> </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
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 <Controller
control={control} control={control}
name="approvals" name="approvals"
@@ -289,6 +287,19 @@ export const AccessPolicyForm = ({
</FormControl> </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 <Controller
control={control} control={control}
name="enforcementLevel" name="enforcementLevel"
@@ -298,13 +309,23 @@ export const AccessPolicyForm = ({
label="Enforcement Level" label="Enforcement Level"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
tooltipText="Determines the level of enforcement for required approvers of a request" tooltipText={
helperText={ <>
<div className="ml-1"> <p>
{field.value === EnforcementLevel.Hard Determines the level of enforcement for required approvers of a request:
? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.` </p>
: `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`} <p className="mt-2">
</div> <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 <Select
@@ -323,6 +344,45 @@ export const AccessPolicyForm = ({
</FormControl> </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)" }}
align="start"
>
<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}`} value={value}
iconPos="right" onChange={onChange}
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"
} }
className="text-left" value={value}
onChange={onChange}
/> />
</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>
)} )}