From 796d5e3540e3039749401fd75e335e2a1e8a761e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 20 Mar 2024 14:47:12 -0700 Subject: [PATCH] Complete preliminary list, update, create group in project --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 16 + .../src/db/migrations/20240318183910_group.ts | 45 +- .../schemas/group-project-membership-roles.ts | 31 ++ .../db/schemas/group-project-memberships.ts | 24 + backend/src/db/schemas/index.ts | 2 + backend/src/db/schemas/models.ts | 2 + backend/src/ee/routes/v1/group-router.ts | 45 +- .../src/ee/services/group/group-service.ts | 27 +- backend/src/ee/services/group/group-types.ts | 2 +- .../services/permission/project-permission.ts | 9 + backend/src/server/routes/index.ts | 14 + .../server/routes/v2/group-project-router.ts | 189 ++++++++ backend/src/server/routes/v2/index.ts | 2 + .../group-project/group-project-dal.ts | 99 ++++ .../group-project-membership-role-dal.ts | 10 + .../group-project/group-project-service.ts | 242 +++++++++ .../group-project/group-project-types.ts | 31 ++ .../identity-project-service.ts | 2 +- .../context/ProjectPermissionContext/types.ts | 2 + frontend/src/hooks/api/groups/types.ts | 21 + frontend/src/hooks/api/workspace/index.tsx | 7 +- frontend/src/hooks/api/workspace/queries.tsx | 93 +++- frontend/src/hooks/api/workspace/types.ts | 18 + .../OrgGroupsSection/OrgGroupMembersModal.tsx | 66 ++- .../OrgGroupsSection/OrgGroupsSection.tsx | 1 - .../OrgGroupsSection/OrgGroupsTable.tsx | 2 +- .../views/Project/MembersPage/MembersPage.tsx | 15 +- .../components/GroupsTab/GroupsTab.tsx | 19 + .../components/GroupsSection/GroupModal.tsx | 187 +++++++ .../components/GroupsSection/GroupRoles.tsx | 458 ++++++++++++++++++ .../GroupsSection/GroupsSection.tsx | 91 ++++ .../components/GroupsSection/GroupsTable.tsx | 103 ++++ .../components/GroupsSection/index.tsx | 1 + .../components/GroupsTab/components/index.tsx | 1 + .../components/GroupsTab/index.tsx | 1 + .../Project/MembersPage/components/index.tsx | 1 + 37 files changed, 1825 insertions(+), 56 deletions(-) create mode 100644 backend/src/db/schemas/group-project-membership-roles.ts create mode 100644 backend/src/db/schemas/group-project-memberships.ts create mode 100644 backend/src/server/routes/v2/group-project-router.ts create mode 100644 backend/src/services/group-project/group-project-dal.ts create mode 100644 backend/src/services/group-project/group-project-membership-role-dal.ts create mode 100644 backend/src/services/group-project/group-project-service.ts create mode 100644 backend/src/services/group-project/group-project-types.ts create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8d36444c05..de1df20beb 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -22,6 +22,7 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -87,6 +88,7 @@ declare module "fastify" { superAdmin: TSuperAdminServiceFactory; user: TUserServiceFactory; group: TGroupServiceFactory; + groupProject: TGroupProjectServiceFactory; apiKey: TApiKeyServiceFactory; project: TProjectServiceFactory; projectMembership: TProjectMembershipServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index e69630781f..cf27ac9aa0 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -23,6 +23,12 @@ import { TGitAppOrg, TGitAppOrgInsert, TGitAppOrgUpdate, + TGroupProjectMembershipRoles, + TGroupProjectMembershipRolesInsert, + TGroupProjectMembershipRolesUpdate, + TGroupProjectMemberships, + TGroupProjectMembershipsInsert, + TGroupProjectMembershipsUpdate, TGroups, TGroupsInsert, TGroupsUpdate, @@ -199,6 +205,16 @@ declare module "knex/types/tables" { TUserGroupMembershipInsert, TUserGroupMembershipUpdate >; + [TableName.GroupProjectMembership]: Knex.CompositeTableType< + TGroupProjectMemberships, + TGroupProjectMembershipsInsert, + TGroupProjectMembershipsUpdate + >; + [TableName.GroupProjectMembershipRole]: Knex.CompositeTableType< + TGroupProjectMembershipRoles, + TGroupProjectMembershipRolesInsert, + TGroupProjectMembershipRolesUpdate + >; [TableName.UserAliases]: Knex.CompositeTableType; [TableName.UserEncryptionKey]: Knex.CompositeTableType< TUserEncryptionKeys, diff --git a/backend/src/db/migrations/20240318183910_group.ts b/backend/src/db/migrations/20240318183910_group.ts index 435189185f..1c39a6dbac 100644 --- a/backend/src/db/migrations/20240318183910_group.ts +++ b/backend/src/db/migrations/20240318183910_group.ts @@ -33,12 +33,53 @@ export async function up(knex: Knex): Promise { } await createOnUpdateTrigger(knex, TableName.UserGroupMembership); + + if (!(await knex.schema.hasTable(TableName.GroupProjectMembership))) { + await knex.schema.createTable(TableName.GroupProjectMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.GroupProjectMembership); + + if (!(await knex.schema.hasTable(TableName.GroupProjectMembershipRole))) { + await knex.schema.createTable(TableName.GroupProjectMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.GroupProjectMembership).onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.GroupProjectMembershipRole); } export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.Groups); - await dropOnUpdateTrigger(knex, TableName.Groups); + await knex.schema.dropTableIfExists(TableName.GroupProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.GroupProjectMembershipRole); await knex.schema.dropTableIfExists(TableName.UserGroupMembership); await dropOnUpdateTrigger(knex, TableName.UserGroupMembership); + + await knex.schema.dropTableIfExists(TableName.GroupProjectMembership); + await dropOnUpdateTrigger(knex, TableName.GroupProjectMembership); + + await knex.schema.dropTableIfExists(TableName.Groups); + await dropOnUpdateTrigger(knex, TableName.Groups); } diff --git a/backend/src/db/schemas/group-project-membership-roles.ts b/backend/src/db/schemas/group-project-membership-roles.ts new file mode 100644 index 0000000000..d837ca8e7e --- /dev/null +++ b/backend/src/db/schemas/group-project-membership-roles.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GroupProjectMembershipRolesSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroupProjectMembershipRoles = z.infer; +export type TGroupProjectMembershipRolesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TGroupProjectMembershipRolesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/group-project-memberships.ts b/backend/src/db/schemas/group-project-memberships.ts new file mode 100644 index 0000000000..aaf4e4c207 --- /dev/null +++ b/backend/src/db/schemas/group-project-memberships.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GroupProjectMembershipsSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + roleId: z.string().uuid().nullable().optional(), + projectId: z.string(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroupProjectMemberships = z.infer; +export type TGroupProjectMembershipsInsert = Omit, TImmutableDBKeys>; +export type TGroupProjectMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 28b6186fa4..0b8c9c9ffe 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -5,6 +5,8 @@ export * from "./auth-tokens"; export * from "./backup-private-key"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; +export * from "./group-project-membership-roles"; +export * from "./group-project-memberships"; export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 03ab6a041d..6b7e514e18 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -3,6 +3,8 @@ import { z } from "zod"; export enum TableName { Users = "users", Groups = "groups", + GroupProjectMembership = "group_project_memberships", + GroupProjectMembershipRole = "group_project_membership_roles", UserGroupMembership = "user_group_membership", UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index dafc84e0a0..e17f4795bd 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -94,10 +94,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); - // TODO: GET users part of group server.route({ method: "GET", - url: "/:slug/users", // TODO: revise to users? + url: "/:slug/users", onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -120,8 +119,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const users = await server.services.group.getGroupUserMemberships({ - slug: req.params.slug, + const users = await server.services.group.listGroupUsers({ + groupSlug: req.params.slug, actor: req.permission.type, actorId: req.permission.id, orgId: req.permission.orgId as string, @@ -134,20 +133,26 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:groupSlug/users/:username", + url: "/:slug/users/:username", onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ - groupSlug: z.string().trim(), + slug: z.string().trim(), username: z.string().trim() }), response: { - 200: z.object({}) + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) } }, handler: async (req) => { - await server.services.group.createGroupUserMemberships({ - groupSlug: req.params.groupSlug, + const user = await server.services.group.addUserToGroup({ + groupSlug: req.params.slug, username: req.params.username, actor: req.permission.type, actorId: req.permission.id, @@ -155,26 +160,33 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); - return {}; + + return user; } }); server.route({ method: "DELETE", - url: "/:groupSlug/users/:username", + url: "/:slug/users/:username", onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ - groupSlug: z.string().trim(), + slug: z.string().trim(), username: z.string().trim() }), response: { - 200: z.object({}) + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) } }, handler: async (req) => { - await server.services.group.deleteGroupUserMemberships({ - groupSlug: req.params.groupSlug, + const user = await server.services.group.removeUserFromGroup({ + groupSlug: req.params.slug, username: req.params.username, actor: req.permission.type, actorId: req.permission.id, @@ -182,7 +194,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId }); - return {}; + + return user; } }); }; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 908e7bf549..a2e0b517c2 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -154,8 +154,8 @@ export const groupServiceFactory = ({ return group; }; - const getGroupUserMemberships = async ({ - slug, + const listGroupUsers = async ({ + groupSlug, actor, actorId, orgId, @@ -167,19 +167,19 @@ export const groupServiceFactory = ({ const group = await groupDAL.findOne({ orgId, - slug + slug: groupSlug }); if (!group) throw new BadRequestError({ - message: `Failed to find group with slug ${slug}` + message: `Failed to find group with slug ${groupSlug}` }); const users = await groupDAL.findAllGroupMembers(group.orgId, group.id); return users; }; - const createGroupUserMemberships = async ({ + const addUserToGroup = async ({ groupSlug, username, actor, @@ -241,15 +241,15 @@ export const groupServiceFactory = ({ message: `User ${username} is not part of the organization` }); - const t = await userGroupMembershipDAL.create({ + await userGroupMembershipDAL.create({ userId: user.id, groupId: group.id }); - return t; + return user; }; - const deleteGroupUserMemberships = async ({ + const removeUserFromGroup = async ({ groupSlug, username, actor, @@ -279,7 +279,6 @@ export const groupServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); - // get user with username const user = await userDAL.findOne({ username }); @@ -300,20 +299,20 @@ export const groupServiceFactory = ({ message: `User ${username} is not part of the group ${groupSlug}` }); - const t = await userGroupMembershipDAL.delete({ + await userGroupMembershipDAL.delete({ groupId: group.id, userId: user.id }); - return t; + return user; }; return { createGroup, updateGroup, deleteGroup, - getGroupUserMemberships, - createGroupUserMemberships, - deleteGroupUserMemberships + listGroupUsers, + addUserToGroup, + removeUserFromGroup }; }; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index a62a81bbdc..36f47a4ac7 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -20,7 +20,7 @@ export type TDeleteGroupDTO = { } & TOrgPermission; export type TGetGroupUserMembershipsDTO = { - slug: string; + groupSlug: string; } & TOrgPermission; export type TCreateGroupUserMembershipDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 46dbdcc3b3..87ba77e013 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -12,6 +12,7 @@ export enum ProjectPermissionActions { export enum ProjectPermissionSub { Role = "role", Member = "member", + Groups = "groups", Settings = "settings", Integrations = "integrations", Webhooks = "webhooks", @@ -41,6 +42,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] @@ -82,6 +84,11 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Create, ProjectPermissionSub.Role); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); @@ -157,6 +164,8 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b27d90dfb5..b091cd27a4 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -50,6 +50,9 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; +import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; @@ -198,6 +201,8 @@ export const registerRoutes = async ( const gitAppInstallSessionDAL = gitAppInstallSessionDALFactory(db); const gitAppOrgDAL = gitAppDALFactory(db); const groupDAL = groupDALFactory(db); + const groupProjectDAL = groupProjectDALFactory(db); + const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); const licenseDAL = licenseDALFactory(db); @@ -247,6 +252,14 @@ export const registerRoutes = async ( permissionService, licenseService }); + const groupProjectService = groupProjectServiceFactory({ + groupDAL, + groupProjectDAL, + groupProjectMembershipRoleDAL, + projectDAL, + projectRoleDAL, + permissionService + }); const scimService = scimServiceFactory({ licenseService, scimDAL, @@ -579,6 +592,7 @@ export const registerRoutes = async ( signup: signupService, user: userService, group: groupService, + groupProject: groupProjectService, permission: permissionService, org: orgService, orgRole: orgRoleService, diff --git a/backend/src/server/routes/v2/group-project-router.ts b/backend/src/server/routes/v2/group-project-router.ts new file mode 100644 index 0000000000..b9c3e548c7 --- /dev/null +++ b/backend/src/server/routes/v2/group-project-router.ts @@ -0,0 +1,189 @@ +import ms from "ms"; +import { z } from "zod"; + +import { + GroupProjectMembershipsSchema, + GroupsSchema, + ProjectMembershipRole, + ProjectUserMembershipRolesSchema +} from "@app/db/schemas"; +import { PROJECTS } from "@app/lib/api-docs"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; + +export const registerGroupProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/group-memberships/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ + projectId: z.string().trim(), + groupSlug: z.string().trim() + }), + body: z.object({ + role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.createProjectGroup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectId: req.params.projectId, + role: req.body.role + }); + return { groupMembership }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/group-memberships/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update project group memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim(), + groupSlug: z.string().trim() + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + }), + response: { + 200: z.object({ + roles: ProjectUserMembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const roles = await server.services.groupProject.updateProjectGroup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectId: req.params.projectId, + roles: req.body.roles + }); + return { roles }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/group-memberships/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete project group memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.DELETE_IDENTITY_MEMBERSHIP.projectId), + groupSlug: z.string().trim().describe(PROJECTS.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.deleteProjectGroup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectId: req.params.projectId + }); + return { groupMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/group-memberships", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Return project group memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_IDENTITY_MEMBERSHIPS.projectId) + }), + response: { + 200: z.object({ + groupMemberships: z + .object({ + id: z.string(), + groupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + 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() + }) + ), + group: GroupsSchema.pick({ name: true, id: true, slug: true }) + }) + .array() + }) + } + }, + handler: async (req) => { + const groupMemberships = await server.services.groupProject.listProjectGroup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId + }); + return { groupMemberships }; + } + }); +}; diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index deebbc9817..3d7581a70b 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,3 +1,4 @@ +import { registerGroupProjectRouter } from "./group-project-router"; import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; @@ -22,6 +23,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (projectServer) => { await projectServer.register(registerProjectRouter); await projectServer.register(registerIdentityProjectRouter); + await projectServer.register(registerGroupProjectRouter); await projectServer.register(registerProjectMembershipRouter); }, { prefix: "/workspace" } diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts new file mode 100644 index 0000000000..3b0523dde3 --- /dev/null +++ b/backend/src/services/group-project/group-project-dal.ts @@ -0,0 +1,99 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; + +export type TGroupProjectDALFactory = ReturnType; + +export const groupProjectDALFactory = (db: TDbClient) => { + const groupProjectOrm = ormify(db, TableName.GroupProjectMembership); + + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership), + db.ref("id").as("groupId").withSchema(TableName.Groups), + db.ref("name").as("groupName").withSchema(TableName.Groups), + db.ref("slug").as("groupSlug").withSchema(TableName.Groups), + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + ); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ groupId, groupName, groupSlug, id, createdAt, updatedAt }) => ({ + id, + groupId, + createdAt, + updatedAt, + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }), + 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: "FindByProjectId" }); + } + }; + + return { ...groupProjectOrm, findByProjectId }; +}; diff --git a/backend/src/services/group-project/group-project-membership-role-dal.ts b/backend/src/services/group-project/group-project-membership-role-dal.ts new file mode 100644 index 0000000000..5572ac6f51 --- /dev/null +++ b/backend/src/services/group-project/group-project-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TGroupProjectMembershipRoleDALFactory = ReturnType; + +export const groupProjectMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GroupProjectMembershipRole); + return orm; +}; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts new file mode 100644 index 0000000000..22db3af9ea --- /dev/null +++ b/backend/src/services/group-project/group-project-service.ts @@ -0,0 +1,242 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectMembershipRole } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; + +import { TGroupDALFactory } from "../../ee/services/group/group-dal"; +import { TProjectDALFactory } from "../project/project-dal"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TGroupProjectDALFactory } from "./group-project-dal"; +import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membership-role-dal"; +import { + TCreateProjectGroupDTO, + TDeleteProjectGroupDTO, + TListProjectGroupDTO, + TUpdateProjectGroupDTO +} from "./group-project-types"; + +type TGroupProjectServiceFactoryDep = { + groupProjectDAL: TGroupProjectDALFactory; + groupProjectMembershipRoleDAL: Pick< + TGroupProjectMembershipRoleDALFactory, + "create" | "transaction" | "insertMany" | "delete" + >; + projectDAL: TProjectDALFactory; + projectRoleDAL: Pick; + groupDAL: Pick; + permissionService: Pick; +}; + +export type TGroupProjectServiceFactory = ReturnType; + +export const groupProjectServiceFactory = ({ + groupDAL, + groupProjectDAL, + groupProjectMembershipRoleDAL, + projectDAL, + projectRoleDAL, + permissionService +}: TGroupProjectServiceFactoryDep) => { + const createProjectGroup = async ({ + groupSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + role + }: TCreateProjectGroupDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const existingGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId }); + if (existingGroup) + throw new BadRequestError({ + message: `Group with slug ${groupSlug} already exists in project with id ${projectId}` + }); + + const project = await projectDAL.findById(projectId); + + const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( + role, + project.id + ); + const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPriviledge) + throw new ForbiddenRequestError({ + message: "Failed to add group to project with more privileged role" + }); + const isCustomRole = Boolean(customRole); + + const projectGroup = await groupProjectDAL.transaction(async (tx) => { + const groupProjectMembership = await groupProjectDAL.create( + { + groupId: group.id, + projectId: project.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + roleId: customRole?.id + }, + tx + ); + + await groupProjectMembershipRoleDAL.create( + { + projectMembershipId: groupProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + customRoleId: customRole?.id + }, + tx + ); + return groupProjectMembership; + }); + return projectGroup; + }; + + const updateProjectGroup = async ({ + projectId, + groupSlug, + roles, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateProjectGroupDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const projectGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId }); + if (!projectGroup) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const { permission: groupRolePermission } = await permissionService.getProjectPermissionByRole( + projectGroup.role, + projectId + ); + + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged group" }); + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const santiziedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: projectGroup.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: projectGroup.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await groupProjectMembershipRoleDAL.transaction(async (tx) => { + await groupProjectMembershipRoleDAL.delete({ projectMembershipId: projectGroup.id }, tx); + return groupProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + }); + + return updatedRoles; + }; + + const deleteProjectGroup = async ({ + groupSlug, + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TDeleteProjectGroupDTO) => { + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const groupProjectMembership = await groupProjectDAL.findOne({ groupId: group.id, projectId }); + if (!groupProjectMembership) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + const { permission: groupRolePermission } = await permissionService.getProjectPermissionByRole( + groupProjectMembership.role, + projectId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged group" }); + + const [deletedGroup] = await groupProjectDAL.delete({ groupId: group.id, projectId }); + return deletedGroup; + }; + + const listProjectGroup = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId }: TListProjectGroupDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + + const groupMemberhips = await groupProjectDAL.findByProjectId(projectId); + return groupMemberhips; + }; + + return { + createProjectGroup, + updateProjectGroup, + deleteProjectGroup, + listProjectGroup + }; +}; diff --git a/backend/src/services/group-project/group-project-types.ts b/backend/src/services/group-project/group-project-types.ts new file mode 100644 index 0000000000..4579fe1224 --- /dev/null +++ b/backend/src/services/group-project/group-project-types.ts @@ -0,0 +1,31 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; + +export type TCreateProjectGroupDTO = { + groupSlug: string; + role: string; +} & TProjectPermission; + +export type TUpdateProjectGroupDTO = { + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; + groupSlug: string; +} & TProjectPermission; + +export type TDeleteProjectGroupDTO = { + groupSlug: string; +} & TProjectPermission; + +export type TListProjectGroupDTO = TProjectPermission; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index d554c81ac5..6eb0b7a2ab 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -226,7 +226,7 @@ export const identityProjectServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - const [deletedIdentity] = await identityProjectDAL.delete({ identityId }); + const [deletedIdentity] = await identityProjectDAL.delete({ identityId }); // TODO: fix return deletedIdentity; }; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 608419f4fb..79c8f2d302 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -10,6 +10,7 @@ export enum ProjectPermissionActions { export enum ProjectPermissionSub { Role = "role", Member = "member", + Groups = "groups", Settings = "settings", Integrations = "integrations", Webhooks = "webhooks", @@ -39,6 +40,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index fb495665c2..3db2357cd3 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -1,5 +1,7 @@ import { TOrgRole } from "../roles/types"; +// TODO: rectify/standardize types + export type TGroupOrgMembership = TGroup & { customRole?: TOrgRole; } @@ -12,4 +14,23 @@ export type TGroup = { createAt: string; updatedAt: string; role: string; +}; + +export type TGroupMembership = { + id: string; + group: TGroup; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; + createdAt: string; + updatedAt: string; }; \ No newline at end of file diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b962230773..870c7a8f28 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,7 +1,9 @@ export { + useAddGroupToWorkspace, useAddIdentityToWorkspace, useCreateWorkspace, useCreateWsEnvironment, + useDeleteGroupFromWorkspace, useDeleteIdentityFromWorkspace, useDeleteUserFromWorkspace, useDeleteWorkspace, @@ -11,6 +13,7 @@ export { useGetUserWorkspaces, useGetWorkspaceAuthorizations, useGetWorkspaceById, + useGetWorkspaceGroupMemberships, useGetWorkspaceIdentityMemberships, useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, @@ -19,8 +22,8 @@ export { useNameWorkspaceSecrets, useRenameWorkspace, useToggleAutoCapitalization, + useUpdateGroupWorkspaceRole, useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, useUpdateWsEnvironment, - useUpgradeProject -} from "./queries"; + useUpgradeProject} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 001454233d..0da76f7f93 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TGroupMembership } from "../groups/types"; import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; @@ -16,6 +17,7 @@ import { RenameWorkspaceDTO, TGetUpgradeProjectStatusDTO, ToggleAutoCapitalizationDTO, + TUpdateWorkspaceGroupRoleDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, UpdateEnvironmentDTO, @@ -36,7 +38,9 @@ export const workspaceKeys = { [{ workspaceId }, "workspace-audit-logs"] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, getWorkspaceIdentityMemberships: (workspaceId: string) => - [{ workspaceId }, "workspace-identity-memberships"] as const + [{ workspaceId }, "workspace-identity-memberships"] as const, + getWorkspaceGroupMemberships: (workspaceId: string) => + [{ workspaceId }, "workspace-group-memberships"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -450,3 +454,90 @@ export const useGetWorkspaceIdentityMemberships = (workspaceId: string) => { enabled: true }); }; + +export const useAddGroupToWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupSlug, + workspaceId, + role + }: { + groupSlug: string; + workspaceId: string; + role?: string; + }) => { + const { + data: { groupMembership } + } = await apiRequest.post( + `/api/v2/workspace/${workspaceId}/group-memberships/${groupSlug}`, + { + role + } + ); + return groupMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(workspaceId)); + } + }); +}; + +export const useUpdateGroupWorkspaceRole = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ groupSlug, workspaceId, roles }: TUpdateWorkspaceGroupRoleDTO) => { + const { + data: { groupMembership } + } = await apiRequest.patch( + `/api/v2/workspace/${workspaceId}/group-memberships/${groupSlug}`, + { + roles + } + ); + + return groupMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(workspaceId)); + } + }); +}; + +export const useDeleteGroupFromWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupSlug, + workspaceId + }: { + groupSlug: string; + workspaceId: string; + }) => { + const { + data: { groupMembership } + } = await apiRequest.delete( + `/api/v2/workspace/${workspaceId}/group-memberships/${groupSlug}` + ); + return groupMembership; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(workspaceId)); + } + }); +}; + +export const useGetWorkspaceGroupMemberships = (workspaceId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceGroupMemberships(workspaceId), + queryFn: async () => { + const { + data: { groupMemberships } + } = await apiRequest.get<{ groupMemberships: TGroupMembership[] }>( + `/api/v2/workspace/${workspaceId}/group-memberships` + ); + return groupMemberships; + }, + enabled: true + }); +}; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index f0cee77415..00f4ec4fe7 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -111,3 +111,21 @@ export type TUpdateWorkspaceIdentityRoleDTO = { } )[]; }; + +export type TUpdateWorkspaceGroupRoleDTO = { + groupSlug: string; + workspaceId: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx index 5b06d32a78..748f8a2e74 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx @@ -1,9 +1,13 @@ -import { faUsers } from "@fortawesome/free-solid-svg-icons"; +import { useMemo,useState } from "react"; +import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, EmptyState, + Input, Modal, ModalContent, Table, @@ -15,6 +19,10 @@ import { THead, Tr } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/context"; import { useCreateGroupUserMembership, useDeleteGroupUserMembership, @@ -23,7 +31,6 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { popUp: UsePopUpState<["groupMembers"]>; -// handlePopUpClose: (popUpName: keyof UsePopUpState<["groupMembers"]>) => void; handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; }; @@ -31,6 +38,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { + const [searchMemberFilter, setSearchMemberFilter] = useState(""); const { createNotification } = useNotificationContext(); const popUpData = popUp?.groupMembers?.data as { @@ -69,6 +77,17 @@ export const OrgGroupMembersModal = ({ } } + const filterdUser = useMemo( + () => + users?.filter( + ({ firstName, lastName, username }) => + firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || + lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || + username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) + ), + [users, searchMemberFilter] + ); + return ( - + setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + - + {isLoading && } - {!isLoading && users?.map(({ + {!isLoading && filterdUser?.map(({ id, firstName, lastName, @@ -100,24 +125,33 @@ export const OrgGroupMembersModal = ({

{`${firstName} ${lastName}`}

{username}

-
); })}
UserStatus
- + - {isPartOfGroup ? "Unassign" : "Assign"} - + {(isAllowed) => { + return ( + + ); + }} +
- {!isLoading && !users?.length && ( + {!isLoading && !filterdUser?.length && ( { /> {(isAllowed) => { return ( diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index b565d426c6..63cb7c895e 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -5,11 +5,12 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components"; +import { GroupsTab, IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components"; enum TabSections { Member = "members", Roles = "roles", + Groups = "groups", Identities = "identities", ServiceTokens = "service-tokens" } @@ -23,6 +24,7 @@ export const MembersPage = withProjectPermission( People + Groups

Machine Identities

@@ -42,6 +44,17 @@ export const MembersPage = withProjectPermission( + + + + + diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx new file mode 100644 index 0000000000..766cc77cb7 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx @@ -0,0 +1,19 @@ +import { motion } from "framer-motion"; + +import { + GroupsSection +} from "./components"; + +export const GroupsTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx new file mode 100644 index 0000000000..6d0948f5ca --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -0,0 +1,187 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Modal, + ModalContent, + Select, + SelectItem} from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { + useAddGroupToWorkspace, + useGetOrganizationGroups, + useGetProjectRoles, + useGetWorkspaceGroupMemberships, +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +// TODO: change this to zod + +const schema = yup + .object({ + slug: yup.string().required("Group slug is required"), + role: yup.string() + }) + .required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["group"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; +}; + +export const GroupModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const orgId = currentOrg?.id || ""; + const workspaceId = currentWorkspace?.id || ""; + + const { data: groups } = useGetOrganizationGroups(orgId); + const { data: groupMemberships } = useGetWorkspaceGroupMemberships(workspaceId); + + const { data: roles } = useGetProjectRoles(workspaceId); + + const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); + + const filteredGroupMembershipOrgs = useMemo(() => { + const wsGroupIds = new Map(); + + groupMemberships?.forEach((groupMembership) => { + wsGroupIds.set(groupMembership.group.id, true); + }); + + return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); + }, [groups, groupMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const onFormSubmit = async ({ slug, role }: FormData) => { + try { + await addGroupToWorkspaceMutateAsync({ + workspaceId, + groupSlug: slug, + role: role || undefined + }); + + reset(); + handlePopUpToggle("group", false); + + createNotification({ + text: "Successfully added group to project", + type: "success" + }); + + } catch (err) { + createNotification({ + text: "Failed to add group to project", + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("group", isOpen); + reset(); + }} + > + + {filteredGroupMembershipOrgs.length ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx new file mode 100644 index 0000000000..6421cf6a70 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -0,0 +1,458 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const IdentityTemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Set Role Temporarily +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + groupSlug: string; + roles: TWorkspaceUser["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const GroupRoles = ({ + roles = [], + disableEdit = false, + groupSlug +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + try { + await updateGroupWorkspaceRole.mutateAsync({ + workspaceId, + groupSlug, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated group role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update group role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx new file mode 100644 index 0000000000..72e6e2d74f --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx @@ -0,0 +1,91 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub,useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; + +import { GroupModal } from "./GroupModal"; +import { GroupTable } from "./GroupsTable"; + +export const GroupsSection = () => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?.id ?? ""; + + const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "group", + "deleteGroup", + "upgradePlan" + ] as const); + + const onRemoveGroupSubmit = async (groupSlug: string) => { + try { + await deleteMutateAsync({ + groupSlug, + workspaceId + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteGroup"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove group from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( +
+
+

Groups

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveGroupSubmit( + (popUp?.deleteGroup?.data as { slug: string })?.slug + ) + } + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx new file mode 100644 index 0000000000..436b402b8a --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx @@ -0,0 +1,103 @@ +import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useGetWorkspaceGroupMemberships } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupRoles } from "./GroupRoles"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteGroup", "group"]>, + data?: { + slug?: string; + name?: string; + } + ) => void; +}; + +export const GroupTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetWorkspaceGroupMemberships(currentWorkspace?.id || ""); + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ group: { id, name, slug }, roles, createdAt }) => { + return ( + + + + + + + ); + })} + +
NameRoleAdded on +
{name} + + {(isAllowed) => ( + + )} + + {format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + slug, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+ {!isLoading && data?.length === 0 && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx new file mode 100644 index 0000000000..7726fd170b --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx new file mode 100644 index 0000000000..7726fd170b --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx new file mode 100644 index 0000000000..419579efc6 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx @@ -0,0 +1 @@ +export { GroupsTab } from "./GroupsTab"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/index.tsx b/frontend/src/views/Project/MembersPage/components/index.tsx index a39af27f75..d7fdb936d0 100644 --- a/frontend/src/views/Project/MembersPage/components/index.tsx +++ b/frontend/src/views/Project/MembersPage/components/index.tsx @@ -1,3 +1,4 @@ +export { GroupsTab } from "./GroupsTab"; export { IdentityTab } from "./IdentityTab"; export { MemberListTab } from "./MemberListTab"; export { ProjectRoleListTab } from "./ProjectRoleListTab";