Merge pull request #2380 from Infisical/daniel/invite-multiple-members-to-project

feat: invite multiple members to projects with role assignment
This commit is contained in:
Daniel Hougaard
2024-09-12 11:16:41 +04:00
committed by GitHub
36 changed files with 348 additions and 357 deletions
+3 -1
View File
@@ -447,7 +447,9 @@ export const PROJECT_USERS = {
INVITE_MEMBER: { INVITE_MEMBER: {
projectId: "The ID of the project to invite the member to.", projectId: "The ID of the project to invite the member to.",
emails: "A list of organization member emails to invite to the project.", emails: "A list of organization member emails to invite to the project.",
usernames: "A list of usernames to invite to the project." usernames: "A list of usernames to invite to the project.",
roleSlugs:
"A list of role slugs to assign to the newly created project membership. If nothing is provided, it will default to the Member role."
}, },
REMOVE_MEMBER: { REMOVE_MEMBER: {
projectId: "The ID of the project to remove the member from.", projectId: "The ID of the project to remove the member from.",
+2 -2
View File
@@ -499,6 +499,7 @@ export const registerRoutes = async (
tokenService, tokenService,
projectUserAdditionalPrivilegeDAL, projectUserAdditionalPrivilegeDAL,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
projectRoleDAL,
projectDAL, projectDAL,
projectMembershipDAL, projectMembershipDAL,
orgMembershipDAL, orgMembershipDAL,
@@ -506,8 +507,7 @@ export const registerRoutes = async (
smtpService, smtpService,
userDAL, userDAL,
groupDAL, groupDAL,
orgBotDAL, orgBotDAL
projectRoleDAL
}); });
const signupService = authSignupServiceFactory({ const signupService = authSignupServiceFactory({
tokenService, tokenService,
@@ -26,7 +26,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
}), }),
body: z.object({ body: z.object({
emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails),
usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames) usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames),
roleSlugs: z.string().array().optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -153,7 +153,6 @@ export const orgAdminServiceFactory = ({
members: [ members: [
{ {
orgMembershipId: membership.id, orgMembershipId: membership.id,
projectMembershipRole: ProjectMembershipRole.Admin,
userPublicKey: userEncryptionKey.publicKey userPublicKey: userEncryptionKey.publicKey
} }
] ]
+2 -2
View File
@@ -114,11 +114,11 @@ export const orgServiceFactory = ({
tokenService, tokenService,
orgBotDAL, orgBotDAL,
licenseService, licenseService,
projectRoleDAL,
samlConfigDAL, samlConfigDAL,
userGroupMembershipDAL, userGroupMembershipDAL,
projectBotDAL, projectBotDAL,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL
projectRoleDAL
}: TOrgServiceFactoryDep) => { }: TOrgServiceFactoryDep) => {
/* /*
* Get organization details by the organization id * Get organization details by the organization id
@@ -1,190 +0,0 @@
import { Knex } from "knex";
import { ProjectMembershipRole, SecretKeyEncoding, TProjectMemberships } from "@app/db/schemas";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { TOrgDALFactory } from "../org/org-dal";
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 { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TProjectMembershipDALFactory } from "./project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
type TAddMembersToProjectArg = {
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "transaction" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectById" | "findProjectGhostUser">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "insertMany">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findUserGroupMembershipsInProject">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany">;
smtpService: Pick<TSmtpService, "sendMail">;
};
type AddMembersToNonE2EEProjectDTO = {
emails: string[];
usernames: string[];
projectId: string;
projectMembershipRole: ProjectMembershipRole;
sendEmails?: boolean;
};
type AddMembersToNonE2EEProjectOptions = {
tx?: Knex;
throwOnProjectNotFound?: boolean;
};
export const addMembersToProject = ({
orgDAL,
projectDAL,
projectMembershipDAL,
projectKeyDAL,
projectBotDAL,
userGroupMembershipDAL,
projectUserMembershipRoleDAL,
smtpService
}: TAddMembersToProjectArg) => {
// Can create multiple memberships for a singular project, based on user email / username
const addMembersToNonE2EEProject = async (
{ emails, usernames, projectId, projectMembershipRole, sendEmails }: AddMembersToNonE2EEProjectDTO,
options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true }
) => {
const processTransaction = async (tx: Knex) => {
const usernamesAndEmails = [...emails, ...usernames];
const project = await projectDAL.findProjectById(projectId);
if (!project) {
if (options.throwOnProjectNotFound) {
throw new BadRequestError({ message: "Project not found when attempting to add user to project" });
}
return [];
}
const orgMembers = await orgDAL.findOrgMembersByUsername(
project.orgId,
[...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))],
tx
);
if (orgMembers.length !== usernamesAndEmails.length)
throw new BadRequestError({ message: "Some users are not part of org" });
if (!orgMembers.length) return [];
const existingMembers = await projectMembershipDAL.find({
projectId,
$in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) }
});
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId });
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const newWsMembers = assignWorkspaceKeysToMembers({
decryptKey: ghostUserLatestKey,
userPrivateKey: botPrivateKey,
members: orgMembers.map((membership) => ({
orgMembershipId: membership.id,
projectMembershipRole,
userPublicKey: membership.user.publicKey
}))
});
const members: TProjectMemberships[] = [];
const userIdsToExcludeForProjectKeyAddition = new Set(
await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId)
);
const projectMemberships = await projectMembershipDAL.insertMany(
orgMembers.map(({ user }) => ({
projectId,
userId: user.id
})),
tx
);
await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })),
tx
);
members.push(...projectMemberships);
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
await projectKeyDAL.insertMany(
orgMembers
.filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id))
.map(({ user, id }) => ({
encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey,
nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce,
senderId: ghostUser.id,
receiverId: user.id,
projectId
})),
tx
);
if (sendEmails) {
const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string);
const appCfg = getConfig();
if (recipients.length) {
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical project invitation",
recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string),
substitutions: {
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
}
}
return members;
};
if (options.tx) {
return processTransaction(options.tx);
}
return projectMembershipDAL.transaction(processTransaction);
};
return {
addMembersToNonE2EEProject
};
};
@@ -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;
+1 -2
View File
@@ -16,7 +16,7 @@ export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateK
privateKey: userPrivateKey privateKey: userPrivateKey
}); });
const newWsMembers = members.map(({ orgMembershipId, userPublicKey, projectMembershipRole }) => { const newWsMembers = members.map(({ orgMembershipId, userPublicKey }) => {
const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric( const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric(
plaintextProjectKey, plaintextProjectKey,
userPublicKey, userPublicKey,
@@ -25,7 +25,6 @@ export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateK
return { return {
orgMembershipId, orgMembershipId,
projectRole: projectMembershipRole,
workspaceEncryptedKey: inviteeCipherText, workspaceEncryptedKey: inviteeCipherText,
workspaceEncryptedNonce: inviteeNonce workspaceEncryptedNonce: inviteeNonce
}; };
@@ -300,8 +300,7 @@ export const projectQueueFactory = ({
members: [ members: [
{ {
userPublicKey: user.publicKey, userPublicKey: user.publicKey,
orgMembershipId: orgMembership.id, orgMembershipId: orgMembership.id
projectMembershipRole: ProjectMembershipRole.Admin
} }
] ]
}); });
@@ -277,8 +277,7 @@ export const projectServiceFactory = ({
members: [ members: [
{ {
userPublicKey: user.publicKey, userPublicKey: user.publicKey,
orgMembershipId: orgMembership.id, orgMembershipId: orgMembership.id
projectMembershipRole: ProjectMembershipRole.Admin
} }
] ]
}); });
@@ -292,7 +291,7 @@ export const projectServiceFactory = ({
tx tx
); );
await projectUserMembershipRoleDAL.create( await projectUserMembershipRoleDAL.create(
{ projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole }, { projectMembershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin },
tx tx
); );
@@ -1,4 +1,4 @@
import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; import { TProjectKeys } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
@@ -88,7 +88,6 @@ export type AddUserToWsDTO = {
userPrivateKey: string; userPrivateKey: string;
members: { members: {
orgMembershipId: string; orgMembershipId: string;
projectMembershipRole: ProjectMembershipRole;
userPublicKey: string; userPublicKey: string;
}[]; }[];
}; };
@@ -60,7 +60,7 @@ export default function TeamInviteStep(): JSX.Element {
placeholder="[email protected], [email protected]..." placeholder="[email protected], [email protected]..."
/> />
</div> </div>
<div className="mx-auto mt-0 mt-2 flex w-full flex-row items-end justify-end text-sm md:mt-4 md:mb-2 md:min-w-[30rem] md:max-w-md"> <div className="mx-auto mt-2 flex w-full flex-row items-end justify-end text-sm md:mt-4 md:mb-2 md:min-w-[30rem] md:max-w-md">
<Button <Button
onClick={() => { onClick={() => {
if (serverDetails?.emailConfigured) { if (serverDetails?.emailConfigured) {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { userKeys } from "../users/queries"; import { userKeys } from "../users";
import { import {
APIKeyDataV2, APIKeyDataV2,
CreateAPIKeyDataV2DTO, CreateAPIKeyDataV2DTO,
+4 -6
View File
@@ -4,8 +4,8 @@ import { apiRequest } from "@app/config/request";
import { Actor, AuditLog, AuditLogFilters } from "./types"; import { Actor, AuditLog, AuditLogFilters } from "./types";
export const workspaceKeys = { export const auditLogKeys = {
getAuditLogs: (filters: AuditLogFilters, workspaceId: string | null) => getAuditLogs: (workspaceId: string | null, filters: AuditLogFilters) =>
[{ workspaceId, filters }, "audit-logs"] as const, [{ workspaceId, filters }, "audit-logs"] as const,
getAuditLogActorFilterOpts: (workspaceId: string) => getAuditLogActorFilterOpts: (workspaceId: string) =>
[{ workspaceId }, "audit-log-actor-filters"] as const [{ workspaceId }, "audit-log-actor-filters"] as const
@@ -13,9 +13,7 @@ export const workspaceKeys = {
export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string | null) => { export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string | null) => {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: workspaceKeys.getAuditLogs(filters, workspaceId), queryKey: auditLogKeys.getAuditLogs(workspaceId, filters),
enabled: workspaceId !== "",
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const auditLogEndpoint = workspaceId const auditLogEndpoint = workspaceId
? `/api/v1/workspace/${workspaceId}/audit-logs` ? `/api/v1/workspace/${workspaceId}/audit-logs`
@@ -37,7 +35,7 @@ export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string |
export const useGetAuditLogActorFilterOpts = (workspaceId: string) => { export const useGetAuditLogActorFilterOpts = (workspaceId: string) => {
return useQuery({ return useQuery({
queryKey: workspaceKeys.getAuditLogActorFilterOpts(workspaceId), queryKey: auditLogKeys.getAuditLogActorFilterOpts(workspaceId),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<{ actors: Actor[] }>( const { data } = await apiRequest.get<{ actors: Actor[] }>(
`/api/v1/workspace/${workspaceId}/audit-logs/filters/actors` `/api/v1/workspace/${workspaceId}/audit-logs/filters/actors`
+1 -1
View File
@@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request";
import { setAuthToken } from "@app/reactQuery"; import { setAuthToken } from "@app/reactQuery";
import { organizationKeys } from "../organization/queries"; import { organizationKeys } from "../organization/queries";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { import {
ChangePasswordDTO, ChangePasswordDTO,
CompleteAccountDTO, CompleteAccountDTO,
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { caKeys } from "./queries"; import { caKeys } from "./queries";
import { import {
TCertificateAuthority, TCertificateAuthority,
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { caKeys } from "../ca/queries"; import { caKeys } from "../ca/queries";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { certTemplateKeys } from "./queries"; import { certTemplateKeys } from "./queries";
import { import {
TCertificateTemplate, TCertificateTemplate,
@@ -54,7 +54,9 @@ export const useDeleteCertTemplate = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TCertificateTemplate, {}, TDeleteCertificateTemplateDTO>({ return useMutation<TCertificateTemplate, {}, TDeleteCertificateTemplateDTO>({
mutationFn: async (data) => { mutationFn: async (data) => {
const { data: certificateTemplate } = await apiRequest.delete<TCertificateTemplate>(`/api/v1/pki/certificate-templates/${data.id}`); const { data: certificateTemplate } = await apiRequest.delete<TCertificateTemplate>(
`/api/v1/pki/certificate-templates/${data.id}`
);
return certificateTemplate; return certificateTemplate;
}, },
onSuccess: ({ caId }, { projectId, id }) => { onSuccess: ({ caId }, { projectId, id }) => {
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types";
export const useDeleteCert = () => { export const useDeleteCert = () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries"; import { organizationKeys } from "../organization/queries";
import { userKeys } from "../users/queries"; import { userKeys } from "../users/query-keys";
import { groupKeys } from "./queries"; import { groupKeys } from "./queries";
import { TGroup } from "./types"; import { TGroup } from "./types";
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { import {
App, App,
BitBucketWorkspace, BitBucketWorkspace,
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { TCloudIntegration } from "./types"; import { TCloudIntegration } from "./types";
export const integrationQueryKeys = { export const integrationQueryKeys = {
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { pkiAlertKeys } from "./queries"; import { pkiAlertKeys } from "./queries";
import { TCreatePkiAlertDTO, TDeletePkiAlertDTO, TPkiAlert, TUpdatePkiAlertDTO } from "./types"; import { TCreatePkiAlertDTO, TDeletePkiAlertDTO, TPkiAlert, TUpdatePkiAlertDTO } from "./types";
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { pkiCollectionKeys } from "./queries"; import { pkiCollectionKeys } from "./queries";
import { import {
TAddItemToPkiCollectionDTO, TAddItemToPkiCollectionDTO,
@@ -11,7 +11,8 @@ import {
TPkiCollection, TPkiCollection,
TPkiCollectionItem, TPkiCollectionItem,
TRemoveItemFromPkiCollectionDTO, TRemoveItemFromPkiCollectionDTO,
TUpdatePkiCollectionTO} from "./types"; TUpdatePkiCollectionTO
} from "./types";
export const useCreatePkiCollection = () => { export const useCreatePkiCollection = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
+1
View File
@@ -29,3 +29,4 @@ export {
useUpdateOrgMembership, useUpdateOrgMembership,
useUpdateUserAuthMethods useUpdateUserAuthMethods
} from "./queries"; } from "./queries";
export { userKeys } from "./query-keys";
+5 -4
View File
@@ -6,8 +6,8 @@ import {
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace";
import { userKeys } from "./queries"; import { userKeys } from "./query-keys";
import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types"; import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types";
export const useAddUserToWsE2EE = () => { export const useAddUserToWsE2EE = () => {
@@ -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;
}, },
+10 -24
View File
@@ -6,6 +6,8 @@ import { setAuthToken } from "@app/reactQuery";
import { APIKeyDataV2 } from "../apiKeys/types"; import { APIKeyDataV2 } from "../apiKeys/types";
import { TGroupWithProjectMemberships } from "../groups/types"; import { TGroupWithProjectMemberships } from "../groups/types";
import { workspaceKeys } from "../workspace";
import { userKeys } from "./query-keys";
import { import {
AddUserToOrgDTO, AddUserToOrgDTO,
APIKeyData, APIKeyData,
@@ -21,29 +23,6 @@ import {
UserEnc UserEnc
} from "./types"; } from "./types";
export const userKeys = {
getUser: ["user"] as const,
getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
getOrgMembership: (orgId: string, orgMembershipId: string) =>
[{ orgId, orgMembershipId }, "org-membership"] as const,
allOrgMembershipProjectMemberships: (orgId: string) => [orgId, "all-user-memberships"] as const,
forOrgMembershipProjectMemberships: (orgId: string, orgMembershipId: string) =>
[...userKeys.allOrgMembershipProjectMemberships(orgId), { orgMembershipId }] as const,
getOrgMembershipProjectMemberships: (orgId: string, username: string) =>
[{ orgId, username }, "org-membership-project-memberships"] as const,
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
myIp: ["ip"] as const,
myAPIKeys: ["api-keys"] as const,
myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const,
listUserGroupMemberships: (username: string) => ["user-group-memberships", username] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
};
export const fetchUserDetails = async () => { export const fetchUserDetails = async () => {
const { data } = await apiRequest.get<{ user: User & UserEnc }>("/api/v1/user"); const { data } = await apiRequest.get<{ user: User & UserEnc }>("/api/v1/user");
@@ -175,8 +154,15 @@ export const useAddUsersToOrg = () => {
mutationFn: (dto) => { mutationFn: (dto) => {
return apiRequest.post("/api/v1/invite-org/signup", dto); return apiRequest.post("/api/v1/invite-org/signup", dto);
}, },
onSuccess: (_, { organizationId }) => { onSuccess: (_, { organizationId, projects }) => {
queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId));
projects?.forEach((project) => {
if (project.slug) {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(project.slug));
}
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(project.id));
});
} }
}); });
}; };
@@ -0,0 +1,21 @@
export const userKeys = {
getUser: ["user"] as const,
getPrivateKey: ["user"] as const,
userAction: ["user-action"] as const,
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
getOrgMembership: (orgId: string, orgMembershipId: string) =>
[{ orgId, orgMembershipId }, "org-membership"] as const,
allOrgMembershipProjectMemberships: (orgId: string) => [orgId, "all-user-memberships"] as const,
forOrgMembershipProjectMemberships: (orgId: string, orgMembershipId: string) =>
[...userKeys.allOrgMembershipProjectMemberships(orgId), { orgMembershipId }] as const,
getOrgMembershipProjectMemberships: (orgId: string, username: string) =>
[{ orgId, username }, "org-membership-project-memberships"] as const,
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
myIp: ["ip"] as const,
myAPIKeys: ["api-keys"] as const,
myAPIKeysV2: ["api-keys-v2"] as const,
mySessions: ["sessions"] as const,
listUsers: ["user-list"] as const,
listUserGroupMemberships: (username: string) => [{ username }, "user-group-memberships"] as const,
myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const
};
+4 -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;
}; };
@@ -150,9 +151,11 @@ export type DeletOrgMembershipDTO = {
export type AddUserToOrgDTO = { export type AddUserToOrgDTO = {
inviteeEmails: string[]; inviteeEmails: string[];
projects?: { id: string; projectRoleSlug: string[] }[];
organizationRoleSlug: string; organizationRoleSlug: string;
organizationId: string; organizationId: string;
// We need the slug in order to invalidate the groups query. `slug` is only used for invalidation purposes.
projects?: { id: string; slug?: string; projectRoleSlug: string[] }[];
}; };
export type CreateAPIKeyRes = { export type CreateAPIKeyRes = {
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries"; import { workspaceKeys } from "../workspace/query-keys";
import { workflowIntegrationKeys } from "./queries"; import { workflowIntegrationKeys } from "./queries";
import { import {
TDeleteSlackIntegrationDTO, TDeleteSlackIntegrationDTO,
@@ -38,3 +38,4 @@ export {
useUpdateWsEnvironment, useUpdateWsEnvironment,
useUpgradeProject useUpgradeProject
} from "./queries"; } from "./queries";
export { workspaceKeys } from "./query-keys";
@@ -2,8 +2,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { userKeys } from "../users/queries"; import { userKeys } from "../users/query-keys";
import { workspaceKeys } from "./queries"; import { workspaceKeys } from "./query-keys";
import { TUpdateWorkspaceGroupRoleDTO } from "./types"; import { TUpdateWorkspaceGroupRoleDTO } from "./types";
export const useAddGroupToWorkspace = () => { export const useAddGroupToWorkspace = () => {
+2 -44
View File
@@ -14,9 +14,10 @@ import { TIntegration } from "../integrations/types";
import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiAlert } from "../pkiAlerts/types";
import { TPkiCollection } from "../pkiCollections/types"; import { TPkiCollection } from "../pkiCollections/types";
import { EncryptedSecret } from "../secrets/types"; import { EncryptedSecret } from "../secrets/types";
import { userKeys } from "../users/queries"; import { userKeys } from "../users/query-keys";
import { TWorkspaceUser } from "../users/types"; import { TWorkspaceUser } from "../users/types";
import { ProjectSlackConfig } from "../workflowIntegrations/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types";
import { workspaceKeys } from "./query-keys";
import { import {
CreateEnvironmentDTO, CreateEnvironmentDTO,
CreateWorkspaceDTO, CreateWorkspaceDTO,
@@ -34,49 +35,6 @@ import {
Workspace Workspace
} from "./types"; } from "./types";
export const workspaceKeys = {
getWorkspaceById: (workspaceId: string) => [{ workspaceId }, "workspace"] as const,
getWorkspaceSecrets: (workspaceId: string) => [{ workspaceId }, "workspace-secrets"] as const,
getWorkspaceIndexStatus: (workspaceId: string) =>
[{ workspaceId }, "workspace-index-status"] as const,
getProjectUpgradeStatus: (workspaceId: string) => [{ workspaceId }, "workspace-upgrade-status"],
getWorkspaceMemberships: (orgId: string) => [{ orgId }, "workspace-memberships"],
getWorkspaceAuthorization: (workspaceId: string) => [{ workspaceId }, "workspace-authorizations"],
getWorkspaceIntegrations: (workspaceId: string) => [{ workspaceId }, "workspace-integrations"],
getAllUserWorkspace: ["workspaces"] as const,
getWorkspaceAuditLogs: (workspaceId: string) =>
[{ workspaceId }, "workspace-audit-logs"] as const,
getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-identity-memberships"] as const,
getWorkspaceGroupMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-groups"] as const,
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
[{ projectSlug }, "workspace-cas"] as const,
specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) =>
[...workspaceKeys.getWorkspaceCas({ projectSlug }), { status }] as const,
allWorkspaceCertificates: () => ["workspace-certificates"] as const,
forWorkspaceCertificates: (slug: string) =>
[...workspaceKeys.allWorkspaceCertificates(), slug] as const,
specificWorkspaceCertificates: ({
slug,
offset,
limit
}: {
slug: string;
offset: number;
limit: number;
}) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const,
getWorkspacePkiAlerts: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-alerts"] as const,
getWorkspacePkiCollections: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-collections"] as const,
getWorkspaceCertificateTemplates: (workspaceId: string) =>
[{ workspaceId }, "workspace-certificate-templates"] as const,
getWorkspaceSlackConfig: (workspaceId: string) =>
[{ workspaceId }, "workspace-slack-config"] as const
};
const fetchWorkspaceById = async (workspaceId: string) => { const fetchWorkspaceById = async (workspaceId: string) => {
const { data } = await apiRequest.get<{ workspace: Workspace }>( const { data } = await apiRequest.get<{ workspace: Workspace }>(
`/api/v1/workspace/${workspaceId}` `/api/v1/workspace/${workspaceId}`
@@ -0,0 +1,44 @@
import type { CaStatus } from "../ca";
export const workspaceKeys = {
getWorkspaceById: (workspaceId: string) => [{ workspaceId }, "workspace"] as const,
getWorkspaceSecrets: (workspaceId: string) => [{ workspaceId }, "workspace-secrets"] as const,
getWorkspaceIndexStatus: (workspaceId: string) =>
[{ workspaceId }, "workspace-index-status"] as const,
getProjectUpgradeStatus: (workspaceId: string) => [{ workspaceId }, "workspace-upgrade-status"],
getWorkspaceMemberships: (orgId: string) => [{ orgId }, "workspace-memberships"],
getWorkspaceAuthorization: (workspaceId: string) => [{ workspaceId }, "workspace-authorizations"],
getWorkspaceIntegrations: (workspaceId: string) => [{ workspaceId }, "workspace-integrations"],
getAllUserWorkspace: ["workspaces"] as const,
getWorkspaceAuditLogs: (workspaceId: string) =>
[{ workspaceId }, "workspace-audit-logs"] as const,
getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const,
getWorkspaceIdentityMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-identity-memberships"] as const,
getWorkspaceGroupMemberships: (workspaceId: string) =>
[{ workspaceId }, "workspace-groups"] as const,
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
[{ projectSlug }, "workspace-cas"] as const,
specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) =>
[...workspaceKeys.getWorkspaceCas({ projectSlug }), { status }] as const,
allWorkspaceCertificates: () => ["workspace-certificates"] as const,
forWorkspaceCertificates: (slug: string) =>
[...workspaceKeys.allWorkspaceCertificates(), slug] as const,
specificWorkspaceCertificates: ({
slug,
offset,
limit
}: {
slug: string;
offset: number;
limit: number;
}) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const,
getWorkspacePkiAlerts: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-alerts"] as const,
getWorkspacePkiCollections: (workspaceId: string) =>
[{ workspaceId }, "workspace-pki-collections"] as const,
getWorkspaceCertificateTemplates: (workspaceId: string) =>
[{ workspaceId }, "workspace-certificate-templates"] as const,
getWorkspaceSlackConfig: (workspaceId: string) =>
[{ workspaceId }, "workspace-slack-config"] as const
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextRouter, useRouter } from "next/router";
import { useServerConfig } from "@app/context"; import { useServerConfig } from "@app/context";
import { useSelectOrganization } from "@app/hooks/api"; import { useSelectOrganization } from "@app/hooks/api";
import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { userKeys } from "@app/hooks/api/users/queries"; import { userKeys } from "@app/hooks/api/users";
import { queryClient } from "@app/reactQuery"; import { queryClient } from "@app/reactQuery";
export const navigateUserToOrg = async (router: NextRouter, organizationId?: string) => { export const navigateUserToOrg = async (router: NextRouter, organizationId?: string) => {
@@ -2,24 +2,37 @@ 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, useAddUsersToOrg,
useAddUserToWsNonE2EE,
useGetOrgUsers, useGetOrgUsers,
useGetUserWsKey, useGetProjectRoles,
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>;
@@ -37,48 +50,52 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const { data: wsKey } = useGetUserWsKey(workspaceId);
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 { data: roles } = useGetProjectRoles(currentWorkspace?.slug || "");
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
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 { mutateAsync: addMembersToProject } = useAddUsersToOrg();
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
const userPrivateKey = localStorage.getItem("PRIVATE_KEY"); const selectedMembers = orgMembershipIds.map((orgMembershipId) =>
if (!userPrivateKey || !wsKey) { orgUsers?.find((orgUser) => orgUser.id === orgMembershipId)
createNotification({ );
text: "Failed to find private key. Try re-login"
}); if (!selectedMembers) return;
return;
}
const orgUser = (orgUsers || []).find(({ id }) => id === orgMembershipId);
if (!orgUser) 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 addMembersToProject({
projectId: workspaceId, inviteeEmails: selectedMembers.map((member) => member?.user.username!),
usernames: [orgUser.user.username], organizationId: orgId,
orgId organizationRoleSlug: ProjectMembershipRole.Member, // ? This doesn't apply in this case, because we know the users being added are already part of the organization
projects: [
{
slug: currentWorkspace.slug,
id: currentWorkspace.id,
projectRoleSlug: projectRoleSlugs
}
]
}); });
} }
createNotification({ createNotification({
@@ -104,6 +121,9 @@ 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");
return ( return (
<Modal <Modal
isOpen={popUp?.addMember?.isOpen} isOpen={popUp?.addMember?.isOpen}
@@ -115,37 +135,183 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
> >
{filteredOrgUsers.length ? ( {filteredOrgUsers.length ? (
<form onSubmit={handleSubmit(onAddMember)}> <form onSubmit={handleSubmit(onAddMember)}>
<div className="flex w-full items-center gap-2">
<div className="w-full">
<Controller <Controller
control={control} control={control}
defaultValue={filteredOrgUsers?.[0]?.user?.username} name="orgMembershipIds"
name="orgMembershipId" render={({ field }) => (
render={({ field, fieldState: { error } }) => ( <FormControl label="Invite users to project">
<FormControl label="Username" isError={Boolean(error)} errorText={error?.message}> <DropdownMenu>
<Select <DropdownMenuTrigger asChild>
position="popper" {filteredOrgUsers && filteredOrgUsers.length > 0 ? (
className="w-full" <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">
defaultValue={filteredOrgUsers?.[0]?.user?.username} {/* eslint-disable-next-line no-nested-ternary */}
value={field.value} {selectedOrgMembershipIds.length === 1
onValueChange={field.onChange} ? 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.map(({ id: orgUserId, user: u }) => ( {filteredOrgUsers && filteredOrgUsers.length > 0 ? (
<SelectItem value={orgUserId} key={`org-membership-join-${orgUserId}`}> filteredOrgUsers.map((member) => {
{u?.username} const isSelected = selectedOrgMembershipIds.includes(member.id);
</SelectItem>
))} return (
</Select> <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> </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 Members
</Button> </Button>
<Button <Button
colorSchema="secondary" colorSchema="secondary"