From 06d8800ee0a1f0993004b52afbd982db9e2db910 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Fri, 23 Aug 2024 00:51:11 +0400 Subject: [PATCH] Feat: Specify organization role and projects when inviting users to org --- backend/src/server/routes/index.ts | 1 + .../src/server/routes/v1/invite-org-router.ts | 6 +- .../src/server/routes/v1/project-router.ts | 28 ++- .../services/auth-token/auth-token-types.ts | 13 +- .../src/services/auth/auth-signup-service.ts | 19 +- backend/src/services/org/org-service.ts | 67 +++++- backend/src/services/org/org-types.ts | 4 +- .../project-membership-fns.ts | 28 ++- .../services/project-role/project-role-fns.ts | 52 +++++ .../project-role/project-role-service.ts | 52 +---- backend/src/services/project/project-fns.ts | 11 + .../src/services/project/project-service.ts | 37 ++- backend/src/services/project/project-types.ts | 4 + .../src/components/signup/TeamInviteStep.tsx | 3 +- frontend/src/hooks/api/roles/queries.tsx | 2 +- frontend/src/hooks/api/users/queries.tsx | 2 +- frontend/src/hooks/api/users/types.ts | 3 +- frontend/src/hooks/api/workspace/queries.tsx | 12 +- frontend/src/hooks/api/workspace/types.ts | 4 + .../OrgMembersSection/AddOrgMemberModal.tsx | 211 +++++++++++------- .../OrgMembersSection/OrgMembersTable.tsx | 3 +- .../components/UserDetailsSection.tsx | 3 +- 22 files changed, 387 insertions(+), 178 deletions(-) create mode 100644 backend/src/services/project-role/project-role-fns.ts diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d1ce11db1..99c9626f4 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -688,6 +688,7 @@ export const registerRoutes = async ( orgDAL, orgService, projectMembershipDAL, + projectRoleDAL, folderDAL, licenseService, certificateAuthorityDAL, diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index be9fdbf74..c2907d3ca 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { UsersSchema } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -19,7 +19,8 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { inviteeEmails: z.array(z.string().trim().email()), organizationId: z.string().trim(), projectIds: z.array(z.string().trim()).optional(), - organizationRoleSlug: z.string().trim().optional() + projectRoleSlug: z.nativeEnum(ProjectMembershipRole).optional(), + organizationRoleSlug: z.nativeEnum(OrgMembershipRole) }), response: { 200: z.object({ @@ -44,6 +45,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { userId: req.permission.id, inviteeEmails: req.body.inviteeEmails, projectIds: req.body.projectIds, + projectRoleSlug: req.body.projectRoleSlug, organizationRoleSlug: req.body.organizationRoleSlug, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index ac9703f07..a380eb934 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { IntegrationsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + IntegrationsSchema, + ProjectMembershipsSchema, + ProjectRolesSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -122,15 +128,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + querystring: z.object({ + includeRoles: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), response: { 200: z.object({ - workspaces: projectWithEnv.array() + workspaces: projectWithEnv + .extend({ + roles: ProjectRolesSchema.array().optional() + }) + .array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), handler: async (req) => { - const workspaces = await server.services.project.getProjects(req.permission.id); + const workspaces = await server.services.project.getProjects({ + includeRoles: req.query.includeRoles, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); return { workspaces }; } }); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index dbe949fd9..65d16850a 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,3 +1,5 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; + export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified @@ -54,9 +56,14 @@ export enum TokenMetadataType { InviteToProjects = "projects-invite" } -// TODO: Split the payloads into separate types for better type inference (not needed until we add more types of metadata) +export type TTokenInviteToProjectsMetadataPayload = { + projectIds: string[]; + projectRoleSlug: ProjectMembershipRole; + userId: string; + orgId: string; +}; + export type TTokenMetadata = { type: TokenMetadataType.InviteToProjects; - projectIds: string[]; - userId: string; + payload: TTokenInviteToProjectsMetadataPayload; }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 8714f5081..33092484e 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,7 +1,7 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; -import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; +import { OrgMembershipStatus, TableName } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -17,7 +17,7 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; +import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; @@ -373,13 +373,17 @@ export const authSignupServiceFactory = ({ if (metadata) { const metadataObj = jwt.verify(metadata, appCfg.AUTH_SECRET) as TTokenMetadata; - if (user.id !== metadataObj.userId) { + if ( + metadataObj.payload.userId !== user.id || + metadataObj.payload.orgId !== orgMembership.orgId || + metadataObj.type !== TokenMetadataType.InviteToProjects + ) { throw new UnauthorizedError({ message: "Malformed or invalid metadata token" }); } - for await (const projectId of metadataObj.projectIds) { + for await (const projectId of metadataObj.payload.projectIds) { await addMembersToProject({ orgDAL, projectDAL, @@ -394,10 +398,13 @@ export const authSignupServiceFactory = ({ emails: [user.email!], usernames: [], projectId, - projectMembershipRole: ProjectMembershipRole.Member, + projectMembershipRole: metadataObj.payload.projectRoleSlug, sendEmails: false }, - tx + { + tx, + throwOnProjectNotFound: false + } ); } } diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index e6c8944ff..4966354c0 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -4,7 +4,14 @@ import crypto from "crypto"; import jwt from "jsonwebtoken"; import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus, ProjectMembershipRole, TableName, TUsers } from "@app/db/schemas"; +import { + OrgMembershipRole, + OrgMembershipStatus, + ProjectMembershipRole, + ProjectVersion, + TableName, + TUsers +} from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -25,8 +32,9 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; -import { TokenMetadataType, TokenType } from "../auth-token/auth-token-types"; +import { TokenMetadataType, TokenType, TTokenMetadata } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { verifyProjectVersion } from "../project/project-fns"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; @@ -435,6 +443,7 @@ export const orgServiceFactory = ({ userId, inviteeEmails, organizationRoleSlug, + projectRoleSlug, projectIds, actorAuthMethod, actorOrgId @@ -467,6 +476,22 @@ export const orgServiceFactory = ({ }); } + if (projectIds?.length) { + const projects = await projectDAL.find({ + orgId, + $in: { + id: projectIds + } + }); + + // if its not v3, throw an error + if (!verifyProjectVersion(projects, ProjectVersion.V3)) { + throw new BadRequestError({ + message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." + }); + } + } + const inviteeUsers = await orgDAL.transaction(async (tx) => { const users: Pick< TUsers & { orgId: string }, @@ -474,6 +499,7 @@ export const orgServiceFactory = ({ >[] = []; for await (const inviteeEmail of inviteeEmails) { const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); + if (inviteeUser) { // if user already exist means its already part of infisical // Thus the signup flow is not needed anymore @@ -504,7 +530,22 @@ export const orgServiceFactory = ({ tx ); - if (projectIds) { + if (projectIds?.length) { + if ( + organizationRoleSlug === OrgMembershipRole.Custom || + projectRoleSlug === ProjectMembershipRole.Custom + ) { + throw new BadRequestError({ + message: "Custom roles are not supported for inviting users to projects and organizations" + }); + } + + if (!projectRoleSlug) { + throw new BadRequestError({ + message: "Selecting a project role is required to invite users to projects" + }); + } + for await (const projectId of projectIds) { await projectMembershipDAL.create( { @@ -528,10 +569,12 @@ export const orgServiceFactory = ({ emails: [inviteeEmail], usernames: [], projectId, - projectMembershipRole: ProjectMembershipRole.Member, + projectMembershipRole: projectRoleSlug, sendEmails: false }, - tx + { + tx + } ); } } @@ -561,7 +604,7 @@ export const orgServiceFactory = ({ inviteEmail: inviteeEmail, orgId, userId: user.id, - role: organizationRoleSlug || OrgMembershipRole.Member, + role: organizationRoleSlug, status: OrgMembershipStatus.Invited, isActive: true }, @@ -588,13 +631,17 @@ export const orgServiceFactory = ({ }); let inviteMetadata: string = ""; - if (projectIds && projectIds.length > 0) { + if (projectIds && projectIds?.length > 0) { inviteMetadata = jwt.sign( { type: TokenMetadataType.InviteToProjects, - projectIds, - userId: invitee.id - }, + payload: { + projectIds, + projectRoleSlug: projectRoleSlug!, // Implicitly checked inside transaction if projectRoleSlug is undefined + userId: invitee.id, + orgId + } + } satisfies TTokenMetadata, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_INVITE_LIFETIME diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index dbc2f7e2e..3fa4ae493 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,3 +1,4 @@ +import { OrgMembershipRole, ProjectMembershipRole } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -30,8 +31,9 @@ export type TInviteUserToOrgDTO = { actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; inviteeEmails: string[]; - organizationRoleSlug?: string; + organizationRoleSlug: OrgMembershipRole; projectIds?: string[]; + projectRoleSlug?: ProjectMembershipRole; }; export type TVerifyUserToOrgDTO = { diff --git a/backend/src/services/project-membership/project-membership-fns.ts b/backend/src/services/project-membership/project-membership-fns.ts index 6395f5324..98acd6eec 100644 --- a/backend/src/services/project-membership/project-membership-fns.ts +++ b/backend/src/services/project-membership/project-membership-fns.ts @@ -35,6 +35,11 @@ type AddMembersToNonE2EEProjectDTO = { sendEmails?: boolean; }; +type AddMembersToNonE2EEProjectOptions = { + tx?: Knex; + throwOnProjectNotFound?: boolean; +}; + export const addMembersToProject = ({ orgDAL, projectDAL, @@ -45,15 +50,22 @@ export const addMembersToProject = ({ 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, - transaction?: Knex + options: AddMembersToNonE2EEProjectOptions = { throwOnProjectNotFound: true } ) => { const processTransaction = async (tx: Knex) => { const usernamesAndEmails = [...emails, ...usernames]; const project = await projectDAL.findProjectById(projectId); - if (!project) throw new BadRequestError({ message: "Project not found" }); + 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, @@ -61,12 +73,6 @@ export const addMembersToProject = ({ tx ); - // const allorgMembers = await orgDAL.findAllOrgMembers(project.orgId,); - - console.log("usernameandena", usernamesAndEmails); - console.log("orgMembers", orgMembers); - // console.log("allorgMembers", allorgMembers); - if (orgMembers.length !== usernamesAndEmails.length) throw new BadRequestError({ message: "Some users are not part of org" }); @@ -131,7 +137,7 @@ export const addMembersToProject = ({ tx ); await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: projectMembershipRole })), tx ); @@ -172,8 +178,8 @@ export const addMembersToProject = ({ return members; }; - if (transaction) { - return processTransaction(transaction); + if (options.tx) { + return processTransaction(options.tx); } return projectMembershipDAL.transaction(processTransaction); }; diff --git a/backend/src/services/project-role/project-role-fns.ts b/backend/src/services/project-role/project-role-fns.ts new file mode 100644 index 000000000..c465715a7 --- /dev/null +++ b/backend/src/services/project-role/project-role-fns.ts @@ -0,0 +1,52 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; +import { + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission +} from "@app/ee/services/permission/project-permission"; + +export const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 8c71a6e02..f60577516 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -5,13 +5,9 @@ import { ProjectMembershipRole } from "@app/db/schemas"; import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { - projectAdminPermissions, - projectMemberPermissions, - projectNoAccessPermissions, ProjectPermissionActions, ProjectPermissionSet, - ProjectPermissionSub, - projectViewerPermission + ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; @@ -20,6 +16,7 @@ import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/id import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { getPredefinedRoles } from "./project-role-fns"; import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { @@ -37,51 +34,6 @@ const unpackPermissions = (permissions: unknown) => unpackRules((permissions || []) as PackRule>>[]) ); -const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { - return [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - permissions: projectAdminPermissions, - description: "Full administrative access over a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - permissions: projectMemberPermissions, - description: "Limited read/write role in a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - permissions: projectViewerPermission, - description: "Only read role in a project", - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: ProjectMembershipRole.NoAccess, - permissions: projectNoAccessPermissions, - description: "No access to any resources in the project", - createdAt: new Date(), - updatedAt: new Date() - } - ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); -}; - export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index d6b010e0b..f67882c0e 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,5 +1,6 @@ import crypto from "crypto"; +import { ProjectVersion, TProjects } from "@app/db/schemas"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -53,6 +54,16 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; }; +export const verifyProjectVersion = (projects: Pick[], version: ProjectVersion) => { + for (const project of projects) { + if (project.version !== version) { + return false; + } + } + + return true; +}; + export const getProjectKmsCertificateKeyId = async ({ projectId, projectDAL, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index a7111d784..bc9ed5dee 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -10,6 +10,7 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; @@ -30,6 +31,8 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { getPredefinedRoles } from "../project-role/project-role-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; @@ -44,6 +47,7 @@ import { TListProjectCasDTO, TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, + TListProjectsDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -84,6 +88,7 @@ type TProjectServiceFactoryDep = { orgDAL: Pick; keyStore: Pick; projectBotDAL: Pick; + projectRoleDAL: Pick; kmsService: Pick< TKmsServiceFactory, | "updateProjectSecretManagerKmsKey" @@ -112,6 +117,7 @@ export const projectServiceFactory = ({ projectEnvDAL, licenseService, projectUserMembershipRoleDAL, + projectRoleDAL, identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, @@ -389,8 +395,37 @@ export const projectServiceFactory = ({ return deletedProject; }; - const getProjects = async (actorId: string) => { + const getProjects = async ({ actorId, includeRoles, actor, actorAuthMethod, actorOrgId }: TListProjectsDTO) => { const workspaces = await projectDAL.findAllProjects(actorId); + + if (includeRoles) { + const customRoles = await projectRoleDAL.find({ + $in: { + projectId: workspaces.map((workspace) => workspace.id) + } + }); + + const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId); + + const workspacesWithRoles = await Promise.all( + workspaces.map(async (workspace) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + workspace.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + return { + ...workspace, + roles: [...(workspaceMappedToRoles[workspace.id] || []), ...getPredefinedRoles(workspace.id)] + }; + }) + ); + + return workspacesWithRoles; + } return workspaces; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ee2f1aea9..c0ef2579e 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -75,6 +75,10 @@ export type TDeleteProjectDTO = { actorOrgId: string | undefined; } & Omit; +export type TListProjectsDTO = { + includeRoles: boolean; +} & Omit; + export type TUpgradeProjectDTO = { userPrivateKey: string; } & TProjectPermission; diff --git a/frontend/src/components/signup/TeamInviteStep.tsx b/frontend/src/components/signup/TeamInviteStep.tsx index b6ce4e208..934935a42 100644 --- a/frontend/src/components/signup/TeamInviteStep.tsx +++ b/frontend/src/components/signup/TeamInviteStep.tsx @@ -32,7 +32,8 @@ export default function TeamInviteStep(): JSX.Element { .map(async (email) => { mutateAsync({ inviteeEmails: [email], - organizationId: String(localStorage.getItem("orgData.id")) + organizationId: String(localStorage.getItem("orgData.id")), + organizationRoleSlug: "member" }); }); diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 865d28789..77e82e4fa 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -47,7 +47,7 @@ export const roleQueryKeys = { ["user-project-permissions", { workspaceId }] as const }; -const getProjectRoles = async (projectId: string) => { +export const getProjectRoles = async (projectId: string) => { const { data } = await apiRequest.get<{ roles: Array> }>( `/api/v1/workspace/${projectId}/roles` ); diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 402781e1c..ead567bee 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -162,7 +162,7 @@ export const useAddUsersToOrg = () => { type Response = { data: { message: string; - completeInviteLinks: { + completeInviteLinks?: { email: string; link: string; }[]; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index aa0c2b9ac..550159fb4 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -151,7 +151,8 @@ export type DeletOrgMembershipDTO = { export type AddUserToOrgDTO = { inviteeEmails: string[]; projectIds?: string[]; - organizationRoleSlug?: string; + projectRoleSlug?: string; + organizationRoleSlug: string; organizationId: string; }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 8f1b065da..9ca3bbf34 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -138,8 +138,12 @@ export const useGetUpgradeProjectStatus = ({ }); }; -const fetchUserWorkspaces = async () => { - const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace"); +const fetchUserWorkspaces = async (includeRoles?: boolean) => { + const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", { + params: { + includeRoles + } + }); return data.workspaces; }; @@ -171,8 +175,8 @@ export const useGetWorkspaceById = ( }); }; -export const useGetUserWorkspaces = () => - useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); +export const useGetUserWorkspaces = (includeRoles?: boolean) => + useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles)); const fetchUserWorkspaceMemberships = async (orgId: string) => { const { data } = await apiRequest.get>( diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 51bb08e3d..efb31b330 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -1,3 +1,5 @@ +import { TProjectRole } from "../roles/types"; + export enum ProjectVersion { V1 = 1, V2 = 2, @@ -22,6 +24,8 @@ export type Workspace = { auditLogsRetentionDays: number; slug: string; createdAt: string; + + roles?: TProjectRole[]; }; export type WorkspaceEnv = { diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index f0dfe5b77..67a03b18a 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -25,18 +26,21 @@ import { useGetOrgRoles, useGetUserWorkspaces } 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"; import { OrgInviteLink } from "./OrgInviteLink"; -const DEFAULT_ORG_MEMBER_ROLE_SLUG = "member"; +const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member"; const EmailSchema = z.string().email().min(1).trim().toLowerCase(); const addMemberFormSchema = z.object({ emails: z.string().min(1).trim().toLowerCase(), projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]), - organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_MEMBER_ROLE_SLUG) + projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), + organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG) }); type TAddMemberForm = z.infer; @@ -71,7 +75,12 @@ export const AddOrgMemberModal = ({ formState: { isSubmitting } } = useForm({ resolver: zodResolver(addMemberFormSchema) }); - const onAddMembers = async ({ emails, organizationRoleSlug, projectIds }: TAddMemberForm) => { + const onAddMembers = async ({ + emails, + organizationRoleSlug, + projectIds, + projectRoleSlug + }: TAddMemberForm) => { if (!currentOrg?.id) return; try { @@ -98,7 +107,8 @@ export const AddOrgMemberModal = ({ organizationId: currentOrg?.id, inviteeEmails: emails.split(",").map((email) => email.trim()), organizationRoleSlug, - projectIds + projectIds, + projectRoleSlug }); setCompleteInviteLinks(data?.completeInviteLinks ?? null); @@ -128,7 +138,12 @@ export const AddOrgMemberModal = ({ }; const projectIds = watch("projectIds", []); - const { data: projects } = useGetUserWorkspaces(); + const { data: projects } = useGetUserWorkspaces(true); + + const filteredProjects = useMemo( + () => projects?.filter((project) => project.version === ProjectVersion.V3), + [projects] + ); return (
field.onChange(val)} + > + {Object.entries(ProjectMembershipRole).map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ([_, slug]) => + slug !== "custom" && ( + + {slug.replace("-", " ")} + + ) + )} + +
+ + )} + /> +
diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index e5a67548b..7b02255ef 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -108,7 +108,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro try { const { data } = await addUsersMutateAsync({ organizationId: orgId, - inviteeEmails: [email] + inviteeEmails: [email], + organizationRoleSlug: "member" }); setCompleteInviteLinks(data?.completeInviteLinks || null); diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx index cd886728c..d19106693 100644 --- a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -50,7 +50,8 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => try { const { data } = await inviteUsers({ organizationId: orgId, - inviteeEmails: [email] + inviteeEmails: [email], + organizationRoleSlug: "member" }); // setCompleteInviteLink(data?.completeInviteLink || "");