Feat: Invite multiple users to project with multiple roles

This commit is contained in:
Daniel Hougaard
2024-09-12 11:03:20 +04:00
parent 1581aa088d
commit 93ba29e57f
6 changed files with 252 additions and 48 deletions
@@ -12,6 +12,8 @@ import { TProjectDALFactory } from "../project/project-dal";
import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { assignWorkspaceKeysToMembers } from "../project/project-fns";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { getPredefinedRoles } from "../project-role/project-role-fns";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
@@ -24,6 +26,7 @@ type TAddMembersToProjectArg = {
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">; projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">; userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">; projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">;
smtpService: Pick<TSmtpService, "sendMail">; smtpService: Pick<TSmtpService, "sendMail">;
}; };
@@ -31,7 +34,7 @@ type AddMembersToNonE2EEProjectDTO = {
emails: string[]; emails: string[];
usernames: string[]; usernames: string[];
projectId: string; projectId: string;
projectMembershipRole: ProjectMembershipRole; projectMembershipRoles: string[];
sendEmails?: boolean; sendEmails?: boolean;
}; };
@@ -48,11 +51,12 @@ export const addMembersToProject = ({
projectBotDAL, projectBotDAL,
userGroupMembershipDAL, userGroupMembershipDAL,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
projectRoleDAL,
smtpService smtpService
}: TAddMembersToProjectArg) => { }: TAddMembersToProjectArg) => {
// Can create multiple memberships for a singular project, based on user email / username // Can create multiple memberships for a singular project, based on user email / username
const addMembersToNonE2EEProject = async ( const addMembersToNonE2EEProject = async (
{ emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO, { emails, usernames, projectId, projectMembershipRoles, sendEmails }: AddMembersToNonE2EEProjectDTO,
options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true } options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true }
) => { ) => {
const processTransaction = async (tx: Knex) => { const processTransaction = async (tx: Knex) => {
@@ -119,7 +123,6 @@ export const addMembersToProject = ({
userPrivateKey: botPrivateKey, userPrivateKey: botPrivateKey,
members: orgMembers.map((membership) => ({ members: orgMembers.map((membership) => ({
orgMembershipId: membership.id, orgMembershipId: membership.id,
projectMembershipRole,
userPublicKey: membership.user.publicKey userPublicKey: membership.user.publicKey
})) }))
}); });
@@ -136,10 +139,37 @@ export const addMembersToProject = ({
})), })),
tx tx
); );
await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })), const defaultRoles = getPredefinedRoles(projectId).map((i) => i.slug) as string[];
tx for await (const projectMembershipRole of projectMembershipRoles) {
); // Custom role
if (!defaultRoles.includes(projectMembershipRole)) {
const role = await projectRoleDAL.findOne({
projectId,
slug: projectMembershipRole
});
if (!role) {
throw new BadRequestError({ message: `Custom role "${projectMembershipRole}" not found` });
}
await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({
projectMembershipId: id,
role: ProjectMembershipRole.Custom,
customRoleId: role.id
})),
tx
);
}
// Predefined role
else {
await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })),
tx
);
}
}
members.push(...projectMemberships); members.push(...projectMemberships);
@@ -42,7 +42,7 @@ type TProjectMembershipServiceFactoryDep = {
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "find" | "delete">; projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "find" | "delete">;
userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">; userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">;
userGroupMembershipDAL: TUserGroupMembershipDALFactory; userGroupMembershipDAL: TUserGroupMembershipDALFactory;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">; projectRoleDAL: Pick<TProjectRoleDALFactory, "find" | "findOne">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">; orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction" | "findProjectById">; projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction" | "findProjectById">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">; projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
@@ -53,4 +53,5 @@ export type TAddUsersToWorkspaceNonE2EEDTO = {
sendEmails?: boolean; sendEmails?: boolean;
emails: string[]; emails: string[];
usernames: string[]; usernames: string[];
roleSlugs?: string[];
} & TProjectPermission; } & TProjectPermission;
+3 -2
View File
@@ -51,9 +51,10 @@ export const useAddUserToWsNonE2EE = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, AddUserToWsDTONonE2EE>({ return useMutation<{}, {}, AddUserToWsDTONonE2EE>({
mutationFn: async ({ projectId, usernames }) => { mutationFn: async ({ projectId, usernames, roleSlugs }) => {
const { data } = await apiRequest.post(`/api/v2/workspace/${projectId}/memberships`, { const { data } = await apiRequest.post(`/api/v2/workspace/${projectId}/memberships`, {
usernames usernames,
roleSlugs
}); });
return data; return data;
}, },
+1
View File
@@ -133,6 +133,7 @@ export type AddUserToWsDTOE2EE = {
export type AddUserToWsDTONonE2EE = { export type AddUserToWsDTONonE2EE = {
projectId: string; projectId: string;
usernames: string[]; usernames: string[];
roleSlugs?: string[];
orgId: string; orgId: string;
}; };
@@ -2,24 +2,38 @@ import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Link from "next/link"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; 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 { useOrganization, useWorkspace } from "@app/context";
import { import {
useAddUserToWsE2EE,
useAddUserToWsNonE2EE, useAddUserToWsNonE2EE,
useGetOrgUsers, useGetOrgUsers,
useGetProjectRoles,
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceUsers useGetWorkspaceUsers
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const addMemberFormSchema = z.object({ 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>; type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
@@ -41,17 +55,22 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data: members } = useGetWorkspaceUsers(workspaceId); const { data: members } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId); const { data: orgUsers } = useGetOrgUsers(orgId);
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
const { data: roles } = useGetProjectRoles(currentWorkspace?.slug || "");
const { const {
control, control,
handleSubmit, handleSubmit,
reset, reset,
watch,
formState: { isSubmitting } 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 (!currentWorkspace) return;
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
// TODO(akhilmhdh): Move to memory storage // TODO(akhilmhdh): Move to memory storage
@@ -62,22 +81,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
}); });
return; return;
} }
const orgUser = (orgUsers || []).find(({ id }) => id === orgMembershipId); const selectedMembers = orgMembershipIds.map((orgMembershipId) =>
if (!orgUser) return; orgUsers?.find((orgUser) => orgUser.id === orgMembershipId)
);
if (!selectedMembers) return;
try { try {
// TODO: update
if (currentWorkspace.version === ProjectVersion.V1) { if (currentWorkspace.version === ProjectVersion.V1) {
await addUserToWorkspace({ createNotification({
workspaceId, type: "error",
userPrivateKey, text: "Please upgrade your project to invite new members to the project."
decryptKey: wsKey,
members: [{ orgMembershipId, userPublicKey: orgUser.user.publicKey }]
}); });
} else { } else {
await addUserToWorkspaceNonE2EE({ await addUserToWorkspaceNonE2EE({
projectId: workspaceId, projectId: workspaceId,
usernames: [orgUser.user.username], usernames: [...selectedMembers.map((member) => member?.user.username!)],
roleSlugs: projectRoleSlugs,
orgId orgId
}); });
} }
@@ -104,6 +124,11 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
return (orgUsers || []).filter(({ user: u }) => !wsUserUsernames.has(u.username)); return (orgUsers || []).filter(({ user: u }) => !wsUserUsernames.has(u.username));
}, [orgUsers, members]); }, [orgUsers, members]);
const selectedOrgMembershipIds = watch("orgMembershipIds");
const selectedRoleSlugs = watch("projectRoleSlugs");
console.log(selectedOrgMembershipIds);
return ( return (
<Modal <Modal
isOpen={popUp?.addMember?.isOpen} isOpen={popUp?.addMember?.isOpen}
@@ -115,35 +140,181 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
> >
{filteredOrgUsers.length ? ( {filteredOrgUsers.length ? (
<form onSubmit={handleSubmit(onAddMember)}> <form onSubmit={handleSubmit(onAddMember)}>
<Controller <div className="flex w-full items-center gap-2">
control={control} <div className="w-full">
defaultValue={filteredOrgUsers?.[0]?.user?.username} <Controller
name="orgMembershipId" control={control}
render={({ field, fieldState: { error } }) => ( name="orgMembershipIds"
<FormControl label="Username" isError={Boolean(error)} errorText={error?.message}> render={({ field }) => (
<Select <FormControl label="Invite users to project">
position="popper" <DropdownMenu>
className="w-full" <DropdownMenuTrigger asChild>
defaultValue={filteredOrgUsers?.[0]?.user?.username} {filteredOrgUsers && filteredOrgUsers.length > 0 ? (
value={field.value} <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">
onValueChange={field.onChange} {/* eslint-disable-next-line no-nested-ternary */}
> {selectedOrgMembershipIds.length === 1
{filteredOrgUsers.map(({ id: orgUserId, user: u }) => ( ? filteredOrgUsers.find(
<SelectItem value={orgUserId} key={`org-membership-join-${orgUserId}`}> (orgUser) => orgUser.id === selectedOrgMembershipIds[0]
{u?.username} )?.user.username
</SelectItem> : selectedOrgMembershipIds.length === 0
))} ? "No users selected"
</Select> : `${selectedOrgMembershipIds.length} users selected`}
</FormControl> <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"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
size="sm" size="sm"
type="submit" type="submit"
isLoading={isSubmitting} isLoading={isSubmitting}
isDisabled={isSubmitting} isDisabled={
isSubmitting ||
selectedOrgMembershipIds.length === 0 ||
selectedRoleSlugs.length === 0
}
> >
Add Member Add Member
</Button> </Button>