mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 15:41:27 +00:00
Feat: Invite multiple users to project with multiple roles
This commit is contained in:
@@ -51,9 +51,10 @@ export const useAddUserToWsNonE2EE = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, AddUserToWsDTONonE2EE>({
|
||||
mutationFn: async ({ projectId, usernames }) => {
|
||||
mutationFn: async ({ projectId, usernames, roleSlugs }) => {
|
||||
const { data } = await apiRequest.post(`/api/v2/workspace/${projectId}/memberships`, {
|
||||
usernames
|
||||
usernames,
|
||||
roleSlugs
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -133,6 +133,7 @@ export type AddUserToWsDTOE2EE = {
|
||||
export type AddUserToWsDTONonE2EE = {
|
||||
projectId: string;
|
||||
usernames: string[];
|
||||
roleSlugs?: string[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
|
||||
+209
-38
@@ -2,24 +2,38 @@ import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddUserToWsE2EE,
|
||||
useAddUserToWsNonE2EE,
|
||||
useGetOrgUsers,
|
||||
useGetProjectRoles,
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
orgMembershipId: z.string().trim()
|
||||
orgMembershipIds: z.array(z.string().trim()).min(1),
|
||||
projectRoleSlugs: z.array(z.string().trim().min(1)).min(1)
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
@@ -41,17 +55,22 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||
const { data: orgUsers } = useGetOrgUsers(orgId);
|
||||
|
||||
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
|
||||
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
|
||||
|
||||
const { data: roles } = useGetProjectRoles(currentWorkspace?.slug || "");
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
|
||||
} = useForm<TAddMemberForm>({
|
||||
resolver: zodResolver(addMemberFormSchema),
|
||||
defaultValues: { orgMembershipIds: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
|
||||
});
|
||||
|
||||
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
|
||||
const onAddMember = async ({ orgMembershipIds, projectRoleSlugs }: TAddMemberForm) => {
|
||||
if (!currentWorkspace) return;
|
||||
if (!currentOrg?.id) return;
|
||||
// TODO(akhilmhdh): Move to memory storage
|
||||
@@ -62,22 +81,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const orgUser = (orgUsers || []).find(({ id }) => id === orgMembershipId);
|
||||
if (!orgUser) return;
|
||||
const selectedMembers = orgMembershipIds.map((orgMembershipId) =>
|
||||
orgUsers?.find((orgUser) => orgUser.id === orgMembershipId)
|
||||
);
|
||||
|
||||
if (!selectedMembers) return;
|
||||
|
||||
try {
|
||||
// TODO: update
|
||||
if (currentWorkspace.version === ProjectVersion.V1) {
|
||||
await addUserToWorkspace({
|
||||
workspaceId,
|
||||
userPrivateKey,
|
||||
decryptKey: wsKey,
|
||||
members: [{ orgMembershipId, userPublicKey: orgUser.user.publicKey }]
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please upgrade your project to invite new members to the project."
|
||||
});
|
||||
} else {
|
||||
await addUserToWorkspaceNonE2EE({
|
||||
projectId: workspaceId,
|
||||
usernames: [orgUser.user.username],
|
||||
usernames: [...selectedMembers.map((member) => member?.user.username!)],
|
||||
roleSlugs: projectRoleSlugs,
|
||||
orgId
|
||||
});
|
||||
}
|
||||
@@ -104,6 +124,11 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (orgUsers || []).filter(({ user: u }) => !wsUserUsernames.has(u.username));
|
||||
}, [orgUsers, members]);
|
||||
|
||||
const selectedOrgMembershipIds = watch("orgMembershipIds");
|
||||
const selectedRoleSlugs = watch("projectRoleSlugs");
|
||||
|
||||
console.log(selectedOrgMembershipIds);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
@@ -115,35 +140,181 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
>
|
||||
{filteredOrgUsers.length ? (
|
||||
<form onSubmit={handleSubmit(onAddMember)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={filteredOrgUsers?.[0]?.user?.username}
|
||||
name="orgMembershipId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Username" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Select
|
||||
position="popper"
|
||||
className="w-full"
|
||||
defaultValue={filteredOrgUsers?.[0]?.user?.username}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{filteredOrgUsers.map(({ id: orgUserId, user: u }) => (
|
||||
<SelectItem value={orgUserId} key={`org-membership-join-${orgUserId}`}>
|
||||
{u?.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="orgMembershipIds"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Invite users to project">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{filteredOrgUsers && filteredOrgUsers.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{selectedOrgMembershipIds.length === 1
|
||||
? filteredOrgUsers.find(
|
||||
(orgUser) => orgUser.id === selectedOrgMembershipIds[0]
|
||||
)?.user.username
|
||||
: selectedOrgMembershipIds.length === 0
|
||||
? "No users selected"
|
||||
: `${selectedOrgMembershipIds.length} users selected`}
|
||||
<FontAwesomeIcon icon={faChevronDown} className="text-xs" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No users found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80"
|
||||
>
|
||||
{filteredOrgUsers && filteredOrgUsers.length > 0 ? (
|
||||
filteredOrgUsers.map((member) => {
|
||||
const isSelected = selectedOrgMembershipIds.includes(member.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) =>
|
||||
filteredOrgUsers.length > 1 && event.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedOrgMembershipIds.includes(String(member.id))) {
|
||||
field.onChange(
|
||||
selectedOrgMembershipIds.filter(
|
||||
(membershipId: string) =>
|
||||
membershipId !== String(member.id)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([
|
||||
...selectedOrgMembershipIds,
|
||||
String(member.id)
|
||||
]);
|
||||
}
|
||||
}}
|
||||
key={`membership-id-${member.id}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{member.user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-fit justify-end">
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlugs"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Select roles"
|
||||
tooltipText="Select the roles that you wish to assign to the users"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{roles && roles.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{selectedRoleSlugs.length === 1
|
||||
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
|
||||
: selectedRoleSlugs.length === 0
|
||||
? "Select at least one role"
|
||||
: `${selectedRoleSlugs.length} roles selected`}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={twMerge("ml-2 text-xs")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No roles found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80"
|
||||
>
|
||||
{roles && roles.length > 0 ? (
|
||||
roles.map((role) => {
|
||||
const isSelected = selectedRoleSlugs.includes(role.slug);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => roles.length > 1 && event.preventDefault()}
|
||||
onClick={() => {
|
||||
if (selectedRoleSlugs.includes(String(role.slug))) {
|
||||
field.onChange(
|
||||
selectedRoleSlugs.filter(
|
||||
(roleSlug: string) => roleSlug !== String(role.slug)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...selectedRoleSlugs, role.slug]);
|
||||
}
|
||||
}}
|
||||
key={`role-slug-${role.slug}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{role.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
isDisabled={
|
||||
isSubmitting ||
|
||||
selectedOrgMembershipIds.length === 0 ||
|
||||
selectedRoleSlugs.length === 0
|
||||
}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user