diff --git a/backend/src/services/project-membership/project-membership-fns.ts b/backend/src/services/project-membership/project-membership-fns.ts index 98acd6eec..baa62628c 100644 --- a/backend/src/services/project-membership/project-membership-fns.ts +++ b/backend/src/services/project-membership/project-membership-fns.ts @@ -12,6 +12,8 @@ import { TProjectDALFactory } from "../project/project-dal"; import { assignWorkspaceKeysToMembers } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-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 { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; @@ -24,6 +26,7 @@ type TAddMembersToProjectArg = { projectBotDAL: Pick; userGroupMembershipDAL: Pick; projectUserMembershipRoleDAL: Pick; + projectRoleDAL: Pick; smtpService: Pick; }; @@ -31,7 +34,7 @@ type AddMembersToNonE2EEProjectDTO = { emails: string[]; usernames: string[]; projectId: string; - projectMembershipRole: ProjectMembershipRole; + projectMembershipRoles: string[]; sendEmails?: boolean; }; @@ -48,11 +51,12 @@ export const addMembersToProject = ({ projectBotDAL, userGroupMembershipDAL, projectUserMembershipRoleDAL, + projectRoleDAL, smtpService }: TAddMembersToProjectArg) => { // Can create multiple memberships for a singular project, based on user email / username const addMembersToNonE2EEProject = async ( - { emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO, + { emails, usernames, projectId, projectMembershipRoles, sendEmails }: AddMembersToNonE2EEProjectDTO, options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true } ) => { const processTransaction = async (tx: Knex) => { @@ -119,7 +123,6 @@ export const addMembersToProject = ({ userPrivateKey: botPrivateKey, members: orgMembers.map((membership) => ({ orgMembershipId: membership.id, - projectMembershipRole, userPublicKey: membership.user.publicKey })) }); @@ -136,10 +139,37 @@ export const addMembersToProject = ({ })), tx ); - await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })), - tx - ); + + const defaultRoles = getPredefinedRoles(projectId).map((i) => i.slug) as string[]; + 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); diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 49a86f31b..e2ed4647d 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -42,7 +42,7 @@ type TProjectMembershipServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; userDAL: Pick; userGroupMembershipDAL: TUserGroupMembershipDALFactory; - projectRoleDAL: Pick; + projectRoleDAL: Pick; orgDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 21efaf691..c50ee4c1b 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -53,4 +53,5 @@ export type TAddUsersToWorkspaceNonE2EEDTO = { sendEmails?: boolean; emails: string[]; usernames: string[]; + roleSlugs?: string[]; } & TProjectPermission; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index eb9bb8007..9ee16415d 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -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; }, diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 6c538811b..048dd3d63 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -133,6 +133,7 @@ export type AddUserToWsDTOE2EE = { export type AddUserToWsDTONonE2EE = { projectId: string; usernames: string[]; + roleSlugs?: string[]; orgId: string; }; diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index 6db92861e..fbf174756 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -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; @@ -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({ resolver: zodResolver(addMemberFormSchema) }); + } = useForm({ + 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 ( { > {filteredOrgUsers.length ? (
- ( - - - - )} - /> +
+
+ ( + + + + {filteredOrgUsers && filteredOrgUsers.length > 0 ? ( +
+ {/* 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`} + +
+ ) : ( +
+ No users found +
+ )} +
+ + {filteredOrgUsers && filteredOrgUsers.length > 0 ? ( + filteredOrgUsers.map((member) => { + const isSelected = selectedOrgMembershipIds.includes(member.id); + + return ( + + 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 ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {member.user.username} + + ); + }) + ) : ( +
+ )} + + + + )} + /> +
+ +
+ ( + + + + {roles && roles.length > 0 ? ( +
+ {/* 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`} + +
+ ) : ( +
+ No roles found +
+ )} +
+ + {roles && roles.length > 0 ? ( + roles.map((role) => { + const isSelected = selectedRoleSlugs.includes(role.slug); + + return ( + 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 ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {role.name} + + ); + }) + ) : ( +
+ )} + + + + )} + /> +
+
+