diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e635d93da..5aae289bf 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -348,10 +348,15 @@ export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." }, + GET_USER_MEMBERSHIP: { + organizationId: "The ID of the organization to get the membership for.", + membershipId: "The ID of the membership to get." + }, UPDATE_USER_MEMBERSHIP: { organizationId: "The ID of the organization to update the membership for.", membershipId: "The ID of the membership to update.", - role: "The new role of the membership." + role: "The new role of the membership.", + isActive: "The active status of the membership" }, DELETE_USER_MEMBERSHIP: { organizationId: "The ID of the organization to delete the membership from.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 18f2d62b1..a0df46dc5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -457,6 +457,7 @@ export const registerRoutes = async ( tokenService, projectDAL, projectMembershipDAL, + orgMembershipDAL, projectKeyDAL, smtpService, userDAL, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 99f05cf94..b6c4ce7bd 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -78,6 +78,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { lastName: true, id: true }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + project: ProjectsSchema.pick({ name: true, id: true }), roles: z.array( z.object({ id: z.string(), diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 07074eba3..49488776b 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -1,6 +1,13 @@ import { z } from "zod"; -import { OrganizationsSchema, OrgMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + OrganizationsSchema, + OrgMembershipsSchema, + ProjectMembershipsSchema, + ProjectsSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { ORGANIZATIONS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -30,6 +37,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { user: UsersSchema.pick({ username: true, email: true, + isEmailVerified: true, firstName: true, lastName: true, id: true @@ -103,6 +111,54 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Get organization user membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.GET_USER_MEMBERSHIP.organizationId), + membershipId: z.string().trim().describe(ORGANIZATIONS.GET_USER_MEMBERSHIP.membershipId) + }), + response: { + 200: z.object({ + membership: OrgMembershipsSchema.merge( + z.object({ + user: UsersSchema.pick({ + username: true, + email: true, + isEmailVerified: true, + firstName: true, + lastName: true, + id: true + }).merge(z.object({ publicKey: z.string().nullable() })) + }) + ).omit({ createdAt: true, updatedAt: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.org.getOrgMembership({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + orgId: req.params.organizationId, + membershipId: req.params.membershipId + }); + return { membership }; + } + }); + server.route({ method: "PATCH", url: "/:organizationId/memberships/:membershipId", @@ -121,7 +177,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { membershipId: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.membershipId) }), body: z.object({ - role: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role) + role: z.string().trim().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role), + isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive) }), response: { 200: z.object({ @@ -129,17 +186,17 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; const membership = await server.services.org.updateOrgMembership({ userId: req.permission.id, - role: req.body.role, actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, membershipId: req.params.membershipId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + ...req.body }); return { membership }; } @@ -183,6 +240,69 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + // TODO: re-think endpoint structure in future so users only need to pass in membershipId bc organizationId is redundant + method: "GET", + url: "/:organizationId/memberships/:membershipId/project-memberships", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Get project memberships given organization membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.organizationId), + membershipId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.membershipId) + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + project: ProjectsSchema.pick({ name: true, id: true }), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) + .omit({ createdAt: true, updatedAt: true }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.org.listProjectMembershipsByOrgMembershipId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + orgId: req.params.organizationId, + orgMembershipId: req.params.membershipId + }); + return { memberships }; + } + }); + server.route({ method: "POST", url: "/", diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 9990d9c3d..ccd995296 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -1,5 +1,6 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TOrgMembershipDALFactory = ReturnType; @@ -7,7 +8,51 @@ export type TOrgMembershipDALFactory = ReturnType { const orgMembershipOrm = ormify(db, TableName.OrgMembership); + const findOrgMembershipById = async (membershipId: string) => { + try { + const member = await db + .replicaNode()(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.id`, membershipId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER + .first(); + + if (!member) return undefined; + + const { email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data } = member; + + return { + ...data, + user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey } + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find org membership by id" }); + } + }; + return { - ...orgMembershipOrm + ...orgMembershipOrm, + findOrgMembershipById }; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index cb611031c..051869429 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -76,6 +76,7 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("status").withSchema(TableName.OrgMembership), db.ref("isActive").withSchema(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), @@ -84,9 +85,9 @@ export const orgDALFactory = (db: TDbClient) => { ) .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER - return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({ + return members.map(({ email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, - user: { email, username, firstName, lastName, id: userId, publicKey } + user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey } })); } catch (error) { throw new DatabaseError({ error, name: "Find all org members" }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index fe67c9041..289491e8d 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -15,9 +15,10 @@ import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; @@ -38,7 +39,9 @@ import { TFindAllWorkspacesDTO, TFindOrgMembersByEmailDTO, TGetOrgGroupsDTO, + TGetOrgMembershipDTO, TInviteUserToOrgDTO, + TListProjectMembershipsByOrgMembershipIdDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, TVerifyUserToOrgDTO @@ -54,6 +57,7 @@ type TOrgServiceFactoryDep = { projectDAL: TProjectDALFactory; projectMembershipDAL: Pick; projectKeyDAL: Pick; + orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; smtpService: TSmtpService; @@ -79,6 +83,7 @@ export const orgServiceFactory = ({ projectDAL, projectMembershipDAL, projectKeyDAL, + orgMembershipDAL, tokenService, orgBotDAL, licenseService, @@ -364,6 +369,7 @@ export const orgServiceFactory = ({ * */ const updateOrgMembership = async ({ role, + isActive, orgId, userId, membershipId, @@ -373,8 +379,13 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + const [foundMembership] = await orgDAL.findMembership({ id: membershipId, orgId }); + if (!foundMembership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (foundMembership.userId === userId) + throw new BadRequestError({ message: "Cannot update own organization membership" }); + const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); - if (isCustomRole) { + if (role && isCustomRole) { const customRole = await orgRoleDAL.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "Update membership", message: "Role not found" }); @@ -394,7 +405,7 @@ export const orgServiceFactory = ({ return membership; } - const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null }); + const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive }); return membership; }; /* @@ -585,6 +596,24 @@ export const orgServiceFactory = ({ return { token, user }; }; + const getOrgMembership = async ({ + membershipId, + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetOrgMembershipDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const membership = await orgMembershipDAL.findOrgMembershipById(membershipId); + if (!membership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (membership.orgId !== orgId) throw new NotFoundError({ message: "Failed to find organization membership" }); + + return membership; + }; + const deleteOrgMembership = async ({ orgId, userId, @@ -608,6 +637,26 @@ export const orgServiceFactory = ({ return deletedMembership; }; + const listProjectMembershipsByOrgMembershipId = async ({ + orgMembershipId, + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListProjectMembershipsByOrgMembershipIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const membership = await orgMembershipDAL.findOrgMembershipById(orgMembershipId); + if (!membership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (membership.orgId !== orgId) throw new NotFoundError({ message: "Failed to find organization membership" }); + + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, membership.user.id); + + return projectMemberships; + }; + /* * CRUD operations of incident contacts * */ @@ -668,6 +717,7 @@ export const orgServiceFactory = ({ findOrgMembersByUsername, createOrganization, deleteOrganizationById, + getOrgMembership, deleteOrgMembership, findAllWorkspaces, addGhostUser, @@ -676,6 +726,7 @@ export const orgServiceFactory = ({ findIncidentContacts, createIncidentContact, deleteIncidentContact, - getOrgGroups + getOrgGroups, + listProjectMembershipsByOrgMembershipId }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 0efc7ffe1..08b4c1c92 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -6,11 +6,16 @@ export type TUpdateOrgMembershipDTO = { userId: string; orgId: string; membershipId: string; - role: string; + role?: string; + isActive?: boolean; actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; }; +export type TGetOrgMembershipDTO = { + membershipId: string; +} & TOrgPermission; + export type TDeleteOrgMembershipDTO = { userId: string; orgId: string; @@ -55,3 +60,7 @@ export type TUpdateOrgDTO = { } & TOrgPermission; export type TGetOrgGroupsDTO = TOrgPermission; + +export type TListProjectMembershipsByOrgMembershipIdDTO = { + orgMembershipId: string; +} & TOrgPermission; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 93ec6597e..19503ba3b 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -16,6 +16,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const docs = await db .replicaNode()(TableName.ProjectMembership) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .where((qb) => { if (filter.usernames) { @@ -58,17 +59,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole) + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project) ) .where({ isGhost: false }); const members = sqlNestRelationships({ data: docs, - parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({ + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId, projectName }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost } + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName + } }), key: "id", childrenMapper: [ @@ -151,14 +157,95 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const findProjectMembershipsByUserId = async (orgId: string, userId: string) => { try { - const memberships = await db + const docs = await db .replicaNode()(TableName.ProjectMembership) - .where({ userId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) - .where({ [`${TableName.Project}.orgId` as "orgId"]: orgId }) - .select(selectAllTableCols(TableName.ProjectMembership)); + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.Users}.id`, userId) + .where(`${TableName.Project}.orgId`, orgId) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("id").as("projectId").withSchema(TableName.Project) + ) + .where({ isGhost: false }); - return memberships; + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, projectId, projectName }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; } catch (error) { throw new DatabaseError({ error, name: "Find project memberships by user id" }); } diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 2657686de..76804c137 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -25,7 +25,7 @@ export const DeleteActionModal = ({ deleteKey, onDeleteApproved, title, - subTitle = "This action is irreversible!", + subTitle = "This action is irreversible.", buttonText = "Delete" }: Props): JSX.Element => { const [inputData, setInputData] = useState(""); @@ -86,7 +86,7 @@ export const DeleteActionModal = ({ - Type {deleteKey} to delete the resource + Type {deleteKey} to perform this action } className="mb-0" @@ -94,7 +94,7 @@ export const DeleteActionModal = ({ setInputData(e.target.value)} - placeholder="Type to delete..." + placeholder="Type confirm..." /> diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 521c36468..2026cede1 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -16,6 +16,8 @@ export { useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, + useGetOrgMembership, + useGetOrgMembershipProjectMemberships, useGetOrgUsers, useGetUser, useGetUserAction, @@ -23,6 +25,5 @@ export { useRegisterUserAction, useRevokeMySessions, useUpdateMfaEnabled, - useUpdateOrgUserRole, - useUpdateUserAuthMethods -} from "./queries"; + useUpdateOrgMembership, + useUpdateUserAuthMethods} from "./queries"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 26e932ac6..eb9bb8007 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -57,8 +57,9 @@ export const useAddUserToWsNonE2EE = () => { }); return data; }, - onSuccess: (_, { projectId }) => { + onSuccess: (_, { orgId, projectId }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(projectId)); + queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId)); } }); }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 5ca47fc63..ea3f44676 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -13,7 +13,8 @@ import { OrgUser, RenameUserDTO, TokenVersion, - UpdateOrgUserRoleDTO, + TWorkspaceUser, + UpdateOrgMembershipDTO, User, UserEnc } from "./types"; @@ -23,6 +24,13 @@ export const userKeys = { 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, @@ -167,6 +175,41 @@ export const useAddUserToOrg = () => { }); }; +export const useGetOrgMembership = (organizationId: string, orgMembershipId: string) => { + return useQuery({ + queryKey: userKeys.getOrgMembership(organizationId, orgMembershipId), + queryFn: async () => { + const { + data: { membership } + } = await apiRequest.get<{ membership: OrgUser }>( + `/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}` + ); + + return membership; + }, + enabled: Boolean(organizationId) && Boolean(orgMembershipId) + }); +}; + +export const useGetOrgMembershipProjectMemberships = ( + organizationId: string, + orgMembershipId: string +) => { + return useQuery({ + queryKey: userKeys.forOrgMembershipProjectMemberships(organizationId, orgMembershipId), + queryFn: async () => { + const { + data: { memberships } + } = await apiRequest.get<{ memberships: TWorkspaceUser[] }>( + `/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}/project-memberships` + ); + + return memberships; + }, + enabled: Boolean(organizationId) && Boolean(orgMembershipId) + }); +}; + export const useDeleteOrgMembership = () => { const queryClient = useQueryClient(); @@ -180,24 +223,43 @@ export const useDeleteOrgMembership = () => { }); }; -export const useUpdateOrgUserRole = () => { +export const useDeactivateOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, UpdateOrgUserRoleDTO>({ - mutationFn: ({ organizationId, membershipId, role }) => { + return useMutation<{}, {}, DeletOrgMembershipDTO>({ + mutationFn: ({ membershipId, orgId }) => { + return apiRequest.post( + `/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate` + ); + }, + onSuccess: (_, { orgId, membershipId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(orgId)); + queryClient.invalidateQueries(userKeys.getOrgMembership(orgId, membershipId)); + } + }); +}; + +export const useUpdateOrgMembership = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, UpdateOrgMembershipDTO>({ + mutationFn: ({ organizationId, membershipId, role, isActive }) => { return apiRequest.patch( `/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { - role + role, + isActive } ); }, - onSuccess: (_, { organizationId }) => { + onSuccess: (_, { organizationId, membershipId }) => { queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId)); }, // to remove old states - onError: (_, { organizationId }) => { + onError: (_, { organizationId, membershipId }) => { queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId)); } }); }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index a3793f942..4e2d7d3a2 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -49,6 +49,7 @@ export type OrgUser = { user: { username: string; email?: string; + isEmailVerified: boolean; firstName: string; lastName: string; id: string; @@ -82,6 +83,11 @@ export type TWorkspaceUser = { id: string; publicKey: string; }; + projectId: string; + project: { + id: string; + name: string; + }; inviteEmail: string; organization: string; roles: ( @@ -127,12 +133,14 @@ export type AddUserToWsDTOE2EE = { export type AddUserToWsDTONonE2EE = { projectId: string; usernames: string[]; + orgId: string; }; -export type UpdateOrgUserRoleDTO = { +export type UpdateOrgMembershipDTO = { organizationId: string; membershipId: string; - role: string; + role?: string; + isActive?: boolean; }; export type DeletOrgMembershipDTO = { diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 1b818fd83..11579b760 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -11,6 +11,7 @@ import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { EncryptedSecret } from "../secrets/types"; +import { userKeys } from "../users/queries"; import { TWorkspaceUser } from "../users/types"; import { CreateEnvironmentDTO, @@ -385,6 +386,7 @@ export const useDeleteUserFromWorkspace = () => { }: { workspaceId: string; usernames: string[]; + orgId: string; }) => { const { data: { deletedMembership } @@ -393,8 +395,9 @@ export const useDeleteUserFromWorkspace = () => { }); return deletedMembership; }, - onSuccess: (_, { workspaceId }) => { + onSuccess: (_, { orgId, workspaceId }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId)); } }); }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index dc0ef370e..be4cf2086 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -264,7 +264,8 @@ export const AppLayout = ({ children }: LayoutProps) => { usernames: orgUsers .map((member) => member.user.username) .filter((username) => username !== user.username), - projectId: newProjectId + projectId: newProjectId, + orgId: currentOrg.id }); } diff --git a/frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx b/frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx new file mode 100644 index 000000000..093b7cd78 --- /dev/null +++ b/frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { UserPage } from "@app/views/Org/UserPage"; + +export default function User() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +User.requireAuth = true; diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 0dc8d50a5..6956f71a3 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -541,7 +541,8 @@ const OrganizationPage = withPermission( usernames: orgUsers .map((member) => member.user.username) .filter((username) => username !== user.username), - projectId: newProjectId + projectId: newProjectId, + orgId: currentOrg.id }); } diff --git a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx index 504c2405e..a005ad1c8 100644 --- a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx +++ b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx @@ -1,4 +1,4 @@ -import { faKey } from "@fortawesome/free-solid-svg-icons"; +import { faFolder } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, @@ -37,7 +37,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => - {isLoading && } + {isLoading && } {!isLoading && projectMemberships?.map((membership) => { return ( @@ -51,7 +51,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {!isLoading && !projectMemberships?.length && ( - + )} ); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index f9dc72f4e..c272145d5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -16,7 +16,7 @@ import { useOrganization, useSubscription } from "@app/context"; -import { useDeleteOrgMembership } from "@app/hooks/api"; +import { useDeleteOrgMembership, useUpdateOrgMembership } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddOrgMemberModal } from "./AddOrgMemberModal"; @@ -32,11 +32,13 @@ export const OrgMembersSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addMember", "removeMember", + "deactivateMember", "upgradePlan", "setUpEmail" ] as const); const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const isMoreUsersAllowed = subscription?.memberLimit ? subscription.membersUsed < subscription.memberLimit @@ -65,6 +67,29 @@ export const OrgMembersSection = () => { handlePopUpOpen("addMember"); }; + const onDeactivateMemberSubmit = async (orgMembershipId: string) => { + try { + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: false + }); + + createNotification({ + text: "Successfully deactivated user in organization", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to deactivate user in organization", + type: "error" + }); + } + + handlePopUpClose("deactivateMember"); + }; + const onRemoveMemberSubmit = async (orgMembershipId: string) => { try { await deleteMutateAsync({ @@ -128,6 +153,20 @@ export const OrgMembersSection = () => { ) } /> + handlePopUpToggle("deactivateMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeactivateMemberSubmit( + (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + buttonText="Deactivate" + /> handlePopUpToggle("upgradePlan", isOpen)} 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 c62b31592..304b104a7 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 @@ -1,13 +1,18 @@ import { useCallback, useMemo, useState } from "react"; -import { faMagnifyingGlass, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { useRouter } from "next/router"; +import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, - IconButton, Input, Select, SelectItem, @@ -32,13 +37,13 @@ import { useFetchServerStatus, useGetOrgRoles, useGetOrgUsers, - useUpdateOrgUserRole + useUpdateOrgMembership } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>, + popUpName: keyof UsePopUpState<["removeMember", "deactivateMember", "upgradePlan"]>, data?: { orgMembershipId?: string; username?: string; @@ -49,6 +54,7 @@ type Props = { }; export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => { + const router = useRouter(); const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const { user } = useUser(); @@ -63,14 +69,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); - const { mutateAsync: updateUserOrgRole } = useUpdateOrgUserRole(); + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const onRoleChange = async (membershipId: string, role: string) => { if (!currentOrg?.id) return; try { // TODO: replace hardcoding default role - const isCustomRole = !["admin", "member"].includes(role); + const isCustomRole = !["admin", "member", "no-access"].includes(role); if (isCustomRole && subscription && !subscription?.rbac) { handlePopUpOpen("upgradePlan", { @@ -79,7 +85,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop return; } - await updateUserOrgRole({ + await updateOrgMembership({ organizationId: currentOrg?.id, membershipId, role @@ -176,7 +182,11 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop const email = u?.email || inviteEmail; const username = u?.username ?? inviteEmail ?? "-"; return ( - + router.push(`/org/${orgId}/memberships/${orgMembershipId}`)} + > {name} {username} @@ -238,34 +248,117 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop {userId !== u?.id && ( - - {(isAllowed) => ( - { - if (currentOrg?.authEnforced) { - createNotification({ - text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", - type: "error" - }); - return; - } - - handlePopUpOpen("removeMember", { orgMembershipId, username }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + + +
+ +
+
+ + - -
- )} -
+ {(isAllowed) => ( + { + e.stopPropagation(); + router.push(`/org/${orgId}/memberships/${orgMembershipId}`); + }} + disabled={!isAllowed} + > + Edit User + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + if (currentOrg?.scimEnabled) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + + if (!isActive) { + // activate user + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: true + }); + + return; + } + + // deactivate user + handlePopUpOpen("deactivateMember", { + orgMembershipId, + username + }); + }} + disabled={!isAllowed} + > + {`${isActive ? "Deactivate" : "Activate"} User`} + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + if (currentOrg?.scimEnabled) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + + handlePopUpOpen("removeMember", { + orgMembershipId, + username + }); + }} + disabled={!isAllowed} + > + Remove User + + )} + + + )} diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx new file mode 100644 index 000000000..eb8b8e8e1 --- /dev/null +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -0,0 +1,278 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { + useDeleteOrgMembership, + useGetOrgMembership, + useUpdateOrgMembership +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components"; + +export const UserPage = withPermission( + () => { + const router = useRouter(); + const membershipId = router.query.membershipId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: membership } = useGetOrgMembership(orgId, membershipId); + + const { mutateAsync: deleteOrgMembership } = useDeleteOrgMembership(); + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeMember", + "orgMembership", + "deactivateMember", + "upgradePlan" + ] as const); + + const onDeactivateMemberSubmit = async (orgMembershipId: string) => { + try { + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: false + }); + + createNotification({ + text: "Successfully deactivated user in organization", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to deactivate user in organization", + type: "error" + }); + } + + handlePopUpClose("deactivateMember"); + }; + + const onRemoveMemberSubmit = async (orgMembershipId: string) => { + try { + await deleteOrgMembership({ + orgId, + membershipId: orgMembershipId + }); + + createNotification({ + text: "Successfully removed user from org", + type: "success" + }); + + handlePopUpClose("removeMember"); + router.push(`/org/${orgId}/members`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove user from the organization", + type: "error" + }); + } + + handlePopUpClose("removeMember"); + }; + + return ( +
+ {membership && ( +
+ +
+

+ {membership.user.firstName || membership.user.lastName + ? `${membership.user.firstName} ${membership.user.lastName}` + : "-"} +

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("orgMembership", { + membershipId: membership.id, + role: membership.role + }) + } + disabled={!isAllowed} + > + Edit User + + )} + + + {(isAllowed) => ( + { + if (currentOrg?.scimEnabled) { + createNotification({ + text: "You cannot manage users from Infisical when SCIM is enabled for your organization", + type: "error" + }); + return; + } + + if (!membership.isActive) { + // activate user + await updateOrgMembership({ + organizationId: orgId, + membershipId, + isActive: true + }); + + return; + } + + // deactivate user + handlePopUpOpen("deactivateMember", { + orgMembershipId: membershipId, + username: membership.user.username + }); + }} + disabled={!isAllowed} + > + {`${membership.isActive ? "Deactivate" : "Activate"} User`} + + )} + + + {(isAllowed) => ( + { + if (currentOrg?.scimEnabled) { + createNotification({ + text: "You cannot manage users from Infisical when SCIM is enabled for your organization", + type: "error" + }); + return; + } + + handlePopUpOpen("removeMember", { + orgMembershipId: membershipId, + username: membership.user.username + }); + }} + disabled={!isAllowed} + > + Remove User + + )} + + +
+
+
+
+ +
+ +
+
+ )} + handlePopUpToggle("removeMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveMemberSubmit( + (popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + /> + handlePopUpToggle("deactivateMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeactivateMemberSubmit( + (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + buttonText="Deactivate" + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } +); diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx new file mode 100644 index 000000000..00c605f05 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -0,0 +1,195 @@ +import { + faCheck, + faCheckCircle, + faCircleXmark, + faCopy, + faPencil} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, IconButton, Tooltip } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useUser +} from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { + useAddUserToOrg, + useFetchServerStatus, + useGetOrgMembership, + useGetOrgRoles +} from "@app/hooks/api"; +import { OrgUser } from "@app/hooks/api/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + membershipId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["orgMembership"]>, data?: {}) => void; +}; + +export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => { + const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset({ + initialState: "Copy username to clipboard" + }); + + const { user } = useUser(); + const { currentOrg } = useOrganization(); + const userId = user?.id || ""; + const orgId = currentOrg?.id || ""; + + const { data: roles } = useGetOrgRoles(orgId); + const { data: serverDetails } = useFetchServerStatus(); + const { data: membership } = useGetOrgMembership(orgId, membershipId); + const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg(); + + const onResendInvite = async (email: string) => { + try { + const { data } = await inviteUser({ + organizationId: orgId, + inviteeEmail: email + }); + + // setCompleteInviteLink(data?.completeInviteLink || ""); + + if (!data.completeInviteLink) { + createNotification({ + text: `Successfully resent invite to ${email}`, + type: "success" + }); + } + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to resend invite to ${email}`, + type: "error" + }); + } + }; + + const getStatus = (m: OrgUser) => { + if (!m.isActive) { + return "Deactivated"; + } + + return m.status === "invited" ? "Invited" : "Active"; + }; + + const roleName = roles?.find((r) => r.slug === membership?.role)?.name; + + return membership ? ( +
+
+

Details

+ {userId !== membership.user.id && ( + + {(isAllowed) => { + return ( + + { + handlePopUpOpen("orgMembership", { + membershipId: membership.id, + role: membership.role + }); + }} + > + + + + ); + }} + + )} +
+
+
+

Name

+

+ {membership.user.firstName || membership.user.lastName + ? `${membership.user.firstName} ${membership.user.lastName}` + : "-"} +

+
+
+

Username

+
+

{membership.user.username}

+
+ + { + navigator.clipboard.writeText(""); + setCopyTextUsername("Copied"); + }} + > + + + +
+
+
+
+

Email

+
+

{membership.user.email ?? "-"}

+ + + +
+
+
+

Organization Role

+

{roleName ?? "-"}

+
+
+

Status

+

{getStatus(membership)}

+
+ {membership.isActive && + (membership.status === "invited" || membership.status === "verified") && + membership.user.email && + serverDetails?.emailConfigured && ( + + {(isAllowed) => { + return ( + + ); + }} + + )} +
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx new file mode 100644 index 000000000..ce3db38d7 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -0,0 +1,160 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + role: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["orgMembership"]>; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>, data?: {}) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["orgMembership"]>, state?: boolean) => void; +}; + +export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { + const { subscription } = useSubscription(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: roles } = useGetOrgRoles(orgId); + + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const popUpData = popUp?.orgMembership?.data as { + membershipId: string; + role: string; + }; + + useEffect(() => { + if (!roles?.length) return; + + if (popUpData) { + reset({ + role: popUpData.role + }); + } else { + reset({ + role: roles[0].slug + }); + } + }, [popUp?.orgMembership?.data, roles]); + + const onFormSubmit = async ({ role }: FormData) => { + try { + if (!orgId) return; + + await updateOrgMembership({ + organizationId: orgId, + membershipId: popUpData.membershipId, + role + }); + + handlePopUpToggle("orgMembership", false); + + createNotification({ + text: "Successfully updated user organization role", + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to update user organization role"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("orgMembership", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx new file mode 100644 index 000000000..2cef4f5f3 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx @@ -0,0 +1,145 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { useAddUserToWsNonE2EE, useGetOrgMembershipProjectMemberships } from "@app/hooks/api"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z + .object({ + projectId: z.string() + }) + .required(); + +type FormData = z.infer; + +type Props = { + membershipId: string; + popUp: UsePopUpState<["addUserToProject"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["addUserToProject"]>, + state?: boolean + ) => void; +}; + +export const UserAddToProjectModal = ({ membershipId, popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { workspaces } = useWorkspace(); + + const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); + + const popupData = popUp.addUserToProject.data as { + username: string; + }; + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const { data: projectMemberships } = useGetOrgMembershipProjectMemberships(orgId, membershipId); + + const filteredWorkspaces = useMemo(() => { + const wsWorkspaceIds = new Map(); + + projectMemberships?.forEach((projectMembership) => { + wsWorkspaceIds.set(projectMembership.project.id, true); + }); + + return (workspaces || []).filter( + ({ id, orgId: projectOrgId, version }) => + !wsWorkspaceIds.has(id) && projectOrgId === currentOrg?.id && version === ProjectVersion.V2 + ); + }, [workspaces, projectMemberships]); + + const onFormSubmit = async ({ projectId }: FormData) => { + try { + await addUserToWorkspaceNonE2EE({ + projectId, + usernames: [popupData.username], + orgId + }); + + createNotification({ + text: "Successfully added user to project", + type: "success" + }); + + reset(); + handlePopUpToggle("addUserToProject", false); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to add identity to project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addUserToProject", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx new file mode 100644 index 000000000..31c3184af --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { useRouter } from "next/router"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + membership: TWorkspaceUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void; +}; + +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Admin) return "Admin"; + if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.Viewer) return "Viewer"; + if (role === ProjectMembershipRole.NoAccess) return "No Access"; + return role; +}; + +export const UserProjectRow = ({ + membership: { id, project, user, roles }, + handlePopUpOpen +}: Props) => { + const { workspaces } = useWorkspace(); + const router = useRouter(); + + const isAccessible = useMemo(() => { + const workspaceIds = new Map(); + + workspaces?.forEach((workspace) => { + workspaceIds.set(workspace.id, true); + }); + + return workspaceIds.has(project.id); + }, [workspaces, project]); + + return ( + { + if (isAccessible) { + router.push(`/project/${project.id}/members`); + return; + } + + createNotification({ + text: "Unable to access project", + type: "error" + }); + }} + > + {project.name} + {`${formatRoleName(roles[0].role, roles[0].customRoleName)}${ + roles.length > 1 ? ` (+${roles.length - 1})` : "" + }`} + + {isAccessible && ( +
+ + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromProject", { + username: user.username, + projectId: project.id, + projectName: project.name + }); + }} + > + + + +
+ )} + + + ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx new file mode 100644 index 000000000..ffd118abf --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx @@ -0,0 +1,98 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useOrganization,useUser } from "@app/context"; +import { useDeleteUserFromWorkspace, useGetOrgMembership } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { UserAddToProjectModal } from "./UserAddToProjectModal"; +import { UserProjectsTable } from "./UserProjectsTable"; + +type Props = { + membershipId: string; +}; + +export const UserProjectsSection = ({ membershipId }: Props) => { + const { user } = useUser(); + const { currentOrg } = useOrganization(); + + const userId = user?.id || ""; + const orgId = currentOrg?.id || ""; + + const { data: membership } = useGetOrgMembership(orgId, membershipId); + + const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addUserToProject", + "removeUserFromProject" + ] as const); + + const handleRemoveUser = async (projectId: string, username: string) => { + try { + await removeUserFromWorkspace({ workspaceId: projectId, usernames: [username], orgId }); + createNotification({ + text: "Successfully removed user from project", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove user from the project", + type: "error" + }); + } + handlePopUpClose("removeUserFromProject"); + }; + + return membership ? ( +
+
+

Projects

+ {userId !== membership.user.id && ( + { + handlePopUpOpen("addUserToProject", { + username: membership.user.username + }); + }} + > + + + )} +
+
+ +
+ + handlePopUpToggle("removeUserFromProject", isOpen)} + onDeleteApproved={() => { + const popupData = popUp?.removeUserFromProject?.data as { + username: string; + projectId: string; + projectName: string; + }; + + return handleRemoveUser(popupData.projectId, popupData.username); + }} + /> +
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx new file mode 100644 index 000000000..88e1a2925 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx @@ -0,0 +1,62 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { UserProjectRow } from "./UserProjectRow"; + +type Props = { + membershipId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void; +}; + +export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships( + orgId, + membershipId + ); + + return ( + + + + + + + + + + {isLoading && } + {!isLoading && + projectMemberships?.map((membership) => { + return ( + + ); + })} + +
NameRole +
+ {!isLoading && !projectMemberships?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx new file mode 100644 index 000000000..b298679d5 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx @@ -0,0 +1 @@ +export { UserProjectsSection } from "./UserProjectsSection"; diff --git a/frontend/src/views/Org/UserPage/components/index.tsx b/frontend/src/views/Org/UserPage/components/index.tsx new file mode 100644 index 000000000..6b13786d4 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/index.tsx @@ -0,0 +1,3 @@ +export { UserDetailsSection } from "./UserDetailsSection"; +export { UserOrgMembershipModal } from "./UserOrgMembershipModal"; +export { UserProjectsSection } from "./UserProjectsSection"; diff --git a/frontend/src/views/Org/UserPage/index.tsx b/frontend/src/views/Org/UserPage/index.tsx new file mode 100644 index 000000000..3338fc5e4 --- /dev/null +++ b/frontend/src/views/Org/UserPage/index.tsx @@ -0,0 +1 @@ +export { UserPage } from "./UserPage"; diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index df76520c2..cd8137d8b 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -6,14 +6,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button,FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddUserToWsE2EE, useAddUserToWsNonE2EE, useGetOrgUsers, useGetUserWsKey, - useGetWorkspaceUsers} from "@app/hooks/api"; + useGetWorkspaceUsers +} from "@app/hooks/api"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -76,7 +77,8 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { } else if (currentWorkspace.version === ProjectVersion.V2) { await addUserToWorkspaceNonE2EE({ projectId: workspaceId, - usernames: [orgUser.user.username] + usernames: [orgUser.user.username], + orgId }); } else { createNotification({ diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx index b4c230e19..db69fc6e6 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx @@ -4,7 +4,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub , useOrganization, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteUserFromWorkspace } from "@app/hooks/api"; @@ -30,7 +35,11 @@ export const MembersSection = () => { if (!currentWorkspace?.id) return; try { - await removeUserFromWorkspace({ workspaceId: currentWorkspace.id, usernames: [username] }); + await removeUserFromWorkspace({ + workspaceId: currentWorkspace.id, + usernames: [username], + orgId: currentOrg.id + }); createNotification({ text: "Successfully removed user from project", type: "success"