Feat: Invite multiple users to project with multiple roles

This commit is contained in:
Daniel Hougaard
2024-09-06 10:40:23 +04:00
parent 1581aa088d
commit 93ba29e57f
6 changed files with 252 additions and 48 deletions

View File

@ -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<TProjectBotDALFactory, "findOne">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">;
smtpService: Pick<TSmtpService, "sendMail">;
};
@ -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);

View File

@ -42,7 +42,7 @@ type TProjectMembershipServiceFactoryDep = {
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "find" | "delete">;
userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">;
userGroupMembershipDAL: TUserGroupMembershipDALFactory;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find" | "findOne">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction" | "findProjectById">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;

View File

@ -53,4 +53,5 @@ export type TAddUsersToWorkspaceNonE2EEDTO = {
sendEmails?: boolean;
emails: string[];
usernames: string[];
roleSlugs?: string[];
} & TProjectPermission;

View File

@ -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;
},

View File

@ -133,6 +133,7 @@ export type AddUserToWsDTOE2EE = {
export type AddUserToWsDTONonE2EE = {
projectId: string;
usernames: string[];
roleSlugs?: string[];
orgId: string;
};

View File

@ -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>