From 686b88fc9792be04b5533c296bb46800b2971abe Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 19 Mar 2024 18:23:52 -0700 Subject: [PATCH] Complete basic pre-cleaned group member assignment/unassignment --- backend/src/@types/knex.d.ts | 8 + .../src/db/migrations/20240318183910_group.ts | 16 ++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../src/db/schemas/user-group-membership.ts | 20 ++ backend/src/ee/routes/v1/group-router.ts | 100 ++++++++- backend/src/ee/services/group/group-dal.ts | 39 ++++ .../src/ee/services/group/group-service.ts | 194 +++++++++++++++++- backend/src/ee/services/group/group-types.ts | 14 ++ .../group/user-group-membership-dal.ts | 13 ++ backend/src/server/routes/index.ts | 5 + frontend/src/hooks/api/groups/index.tsx | 7 +- frontend/src/hooks/api/groups/mutations.tsx | 45 ++++ frontend/src/hooks/api/groups/queries.tsx | 29 +++ .../OrgGroupsSection/OrgGroupMembersModal.tsx | 130 ++++++++++++ .../OrgGroupsSection/OrgGroupModal.tsx | 5 +- .../OrgGroupsSection/OrgGroupsSection.tsx | 7 + .../OrgGroupsSection/OrgGroupsTable.tsx | 26 ++- 18 files changed, 642 insertions(+), 18 deletions(-) create mode 100644 backend/src/db/schemas/user-group-membership.ts create mode 100644 backend/src/ee/services/group/user-group-membership-dal.ts create mode 100644 frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 549eb12bd..e69630781 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -179,6 +179,9 @@ import { TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, + TUserGroupMembership, + TUserGroupMembershipInsert, + TUserGroupMembershipUpdate, TUsers, TUsersInsert, TUsersUpdate, @@ -191,6 +194,11 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; [TableName.Groups]: Knex.CompositeTableType; + [TableName.UserGroupMembership]: Knex.CompositeTableType< + TUserGroupMembership, + TUserGroupMembershipInsert, + TUserGroupMembershipUpdate + >; [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 cc639504c..435189185 100644 --- a/backend/src/db/migrations/20240318183910_group.ts +++ b/backend/src/db/migrations/20240318183910_group.ts @@ -20,9 +20,25 @@ export async function up(knex: Knex): Promise { } await createOnUpdateTrigger(knex, TableName.Groups); + + if (!(await knex.schema.hasTable(TableName.UserGroupMembership))) { + await knex.schema.createTable(TableName.UserGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); // link to user and link to groups cascade on groups + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.UserGroupMembership); } export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.Groups); await dropOnUpdateTrigger(knex, TableName.Groups); + + await knex.schema.dropTableIfExists(TableName.UserGroupMembership); + await dropOnUpdateTrigger(knex, TableName.UserGroupMembership); } diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 33f02cf63..28b6186fa 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -58,5 +58,6 @@ export * from "./trusted-ips"; export * from "./user-actions"; export * from "./user-aliases"; export * from "./user-encryption-keys"; +export * from "./user-group-membership"; export * from "./users"; export * from "./webhooks"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 7d0900a80..03ab6a041 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export enum TableName { Users = "users", Groups = "groups", + UserGroupMembership = "user_group_membership", UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", AuthTokens = "auth_tokens", diff --git a/backend/src/db/schemas/user-group-membership.ts b/backend/src/db/schemas/user-group-membership.ts new file mode 100644 index 000000000..b6345d85a --- /dev/null +++ b/backend/src/db/schemas/user-group-membership.ts @@ -0,0 +1,20 @@ +// 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 UserGroupMembershipSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserGroupMembership = z.infer; +export type TUserGroupMembershipInsert = Omit, TImmutableDBKeys>; +export type TUserGroupMembershipUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 7ca451847..dafc84e0a 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { GroupsSchema, OrgMembershipRole } from "@app/db/schemas"; +import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -69,12 +69,12 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:slug", + url: "/:groupSlug", method: "DELETE", onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ - slug: z.string().trim() + groupSlug: z.string().trim() }), response: { 200: GroupsSchema @@ -82,7 +82,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const group = await server.services.group.deleteGroup({ - slug: req.params.slug, + groupSlug: req.params.groupSlug, actor: req.permission.type, actorId: req.permission.id, orgId: req.permission.orgId as string, // note @@ -93,4 +93,96 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { return group; } }); + + // TODO: GET users part of group + server.route({ + method: "GET", + url: "/:slug/users", // TODO: revise to users? + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim() + }), + response: { + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean() + }) + ) + .array() + } + }, + handler: async (req) => { + const users = await server.services.group.getGroupUserMemberships({ + slug: req.params.slug, + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId as string, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return users; + } + }); + + server.route({ + method: "POST", + url: "/:groupSlug/users/:username", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + groupSlug: z.string().trim(), + username: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.group.createGroupUserMemberships({ + groupSlug: req.params.groupSlug, + username: req.params.username, + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId as string, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return {}; + } + }); + + server.route({ + method: "DELETE", + url: "/:groupSlug/users/:username", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + groupSlug: z.string().trim(), + username: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.group.deleteGroupUserMemberships({ + groupSlug: req.params.groupSlug, + username: req.params.username, + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId as string, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return {}; + } + }); }; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 0f18f932f..a37f877f1 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -39,8 +39,47 @@ export const groupDALFactory = (db: TDbClient) => { } }; + // special query + const findAllGroupMembers = async (orgId: string, groupId: string) => { + try { + const members = await db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserGroupMembership, function () { + this.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("groupId").withSchema(TableName.UserGroupMembership), + 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("id").withSchema(TableName.Users).as("userId") + // db.raw(`CASE WHEN "${TableName.UserGroupMembership}"."groupId" IS NOT NULL THEN TRUE ELSE FALSE END as isPartOfGroup`) + ) + .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER + + return members.map(({ email, username, firstName, lastName, userId, groupId: memberGroupId }) => ({ + id: userId, + email, + username, + firstName, + lastName, + isPartOfGroup: !!memberGroupId + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + return { findByOrgId, + findAllGroupMembers, ...groupOrm }; }; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 725e478b2..908e7bf54 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -2,23 +2,44 @@ import { ForbiddenError } from "@casl/ability"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { TOrgDALFactory } from "../../../services/org/org-dal"; +import { TUserDALFactory } from "../../../services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TGroupDALFactory } from "./group-dal"; -import { TCreateGroupDTO, TDeleteGroupDTO, TUpdateGroupDTO } from "./group-types"; +import { + TCreateGroupDTO, + TCreateGroupUserMembershipDTO, + TDeleteGroupDTO, + TDeleteGroupUserMembershipDTO, + TGetGroupUserMembershipsDTO, + TUpdateGroupDTO +} from "./group-types"; +import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { + // TODO: Pick + userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; + orgDAL: TOrgDALFactory; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; permissionService: Pick; licenseService: Pick; }; export type TGroupServiceFactory = ReturnType; -export const groupServiceFactory = ({ groupDAL, permissionService, licenseService }: TGroupServiceFactoryDep) => { +export const groupServiceFactory = ({ + userDAL, + groupDAL, + orgDAL, + userGroupMembershipDAL, + permissionService, + licenseService +}: TGroupServiceFactoryDep) => { const createGroup = async ({ name, slug, @@ -48,7 +69,7 @@ export const groupServiceFactory = ({ groupDAL, permissionService, licenseServic const group = await groupDAL.create({ name, - slug, + slug, // TODO: slugify orgId, role: isCustomRole ? OrgMembershipRole.Custom : role, roleId: customRole?.id @@ -101,7 +122,7 @@ export const groupServiceFactory = ({ groupDAL, permissionService, licenseServic }, { name, - slug, + slug, // TODO: slugify ...(role ? { role: customRole ? OrgMembershipRole.Custom : role, @@ -114,7 +135,7 @@ export const groupServiceFactory = ({ groupDAL, permissionService, licenseServic return updatedGroup; }; - const deleteGroup = async ({ slug, actor, actorId, orgId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { + const deleteGroup = async ({ groupSlug, actor, actorId, orgId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); @@ -127,15 +148,172 @@ export const groupServiceFactory = ({ groupDAL, permissionService, licenseServic const [group] = await groupDAL.delete({ orgId, - slug + slug: groupSlug }); return group; }; + const getGroupUserMemberships = async ({ + slug, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TGetGroupUserMembershipsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId, + slug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${slug}` + }); + + const users = await groupDAL.findAllGroupMembers(group.orgId, group.id); + return users; + }; + + const createGroupUserMemberships = async ({ + groupSlug, + username, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TCreateGroupUserMembershipDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, orgId); + + // check if user has broader or equal to privileges than group + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); + + // get user with username + const user = await userDAL.findOne({ + username + }); + + if (!user) + throw new BadRequestError({ + message: `Failed to find user with username ${username}` + }); + + // check if user group membership already exists + const existingUserGroupMembership = await userGroupMembershipDAL.findOne({ + groupId: group.id, + userId: user.id + }); + + if (existingUserGroupMembership) + throw new BadRequestError({ + message: `User ${username} is already part of the group ${groupSlug}` + }); + + // check if user is even part of the organization + const existingUserOrgMembership = await orgDAL.findMembership({ + userId: user.id, + orgId + }); + + if (!existingUserOrgMembership) + throw new BadRequestError({ + message: `User ${username} is not part of the organization` + }); + + const t = await userGroupMembershipDAL.create({ + userId: user.id, + groupId: group.id + }); + + return t; + }; + + const deleteGroupUserMemberships = async ({ + groupSlug, + username, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TDeleteGroupUserMembershipDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, orgId); + + // check if user has broader or equal to privileges than group + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); + + // get user with username + const user = await userDAL.findOne({ + username + }); + + if (!user) + throw new BadRequestError({ + message: `Failed to find user with username ${username}` + }); + + // check if user group membership already exists + const existingUserGroupMembership = await userGroupMembershipDAL.findOne({ + groupId: group.id, + userId: user.id + }); + + if (!existingUserGroupMembership) + throw new BadRequestError({ + message: `User ${username} is not part of the group ${groupSlug}` + }); + + const t = await userGroupMembershipDAL.delete({ + groupId: group.id, + userId: user.id + }); + + return t; + }; + return { createGroup, updateGroup, - deleteGroup + deleteGroup, + getGroupUserMemberships, + createGroupUserMemberships, + deleteGroupUserMemberships }; }; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index cb3399dc5..a62a81bbd 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -16,5 +16,19 @@ export type TUpdateGroupDTO = { TOrgPermission; export type TDeleteGroupDTO = { + groupSlug: string; +} & TOrgPermission; + +export type TGetGroupUserMembershipsDTO = { slug: string; } & TOrgPermission; + +export type TCreateGroupUserMembershipDTO = { + groupSlug: string; + username: string; +} & TOrgPermission; + +export type TDeleteGroupUserMembershipDTO = { + groupSlug: string; + username: string; +} & TOrgPermission; diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts new file mode 100644 index 000000000..1fad97441 --- /dev/null +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TUserGroupMembershipDALFactory = ReturnType; + +export const userGroupMembershipDALFactory = (db: TDbClient) => { + const userGroupMembershipOrm = ormify(db, TableName.UserGroupMembership); + + return { + ...userGroupMembershipOrm + }; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 768aa9bc5..b27d90dfb 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -7,6 +7,7 @@ import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-lo import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; +import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; @@ -197,6 +198,7 @@ export const registerRoutes = async ( const gitAppInstallSessionDAL = gitAppInstallSessionDALFactory(db); const gitAppOrgDAL = gitAppDALFactory(db); const groupDAL = groupDALFactory(db); + const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); const licenseDAL = licenseDALFactory(db); @@ -238,7 +240,10 @@ export const registerRoutes = async ( licenseService }); const groupService = groupServiceFactory({ + userDAL, groupDAL, + orgDAL, + userGroupMembershipDAL, permissionService, licenseService }); diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 996425efc..4e2a0cb30 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,4 +1,9 @@ export { useCreateGroup, + useCreateGroupUserMembership, useDeleteGroup, - useUpdateGroup} from "./mutations"; \ No newline at end of file + useDeleteGroupUserMembership, + useUpdateGroup} from "./mutations"; +export { + useGetGroupUserMemberships +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 298fb3e97..1412e679e 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; +import { groupKeys } from "./queries"; import { TGroup } from "./types"; export const useCreateGroup = () => { @@ -84,4 +85,48 @@ export const useDeleteGroup = () => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); } }); +}; + +export const useCreateGroupUserMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + slug, + username + }: { + slug: string; + username: string; + }) => { + const { + data + } = await apiRequest.post(`/api/v1/groups/${slug}/users/${username}`); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries(groupKeys.getGroupUserMembership(slug)); + } + }); +}; + +export const useDeleteGroupUserMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + slug, + username + }: { + slug: string; + username: string; + }) => { + const { + data + } = await apiRequest.delete(`/api/v1/groups/${slug}/users/${username}`); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries(groupKeys.getGroupUserMembership(slug)); + } + }); }; \ No newline at end of file diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index e69de29bb..aec0a92d9 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +export const groupKeys = { + getGroupUserMembership: (slug: string) => [{ slug }, "group-user-memberships"] as const +} + +type TUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; +} + +export const useGetGroupUserMemberships = (groupSlug: string) => { + return useQuery({ + queryKey: groupKeys.getGroupUserMembership(groupSlug), + queryFn: async () => { + const { + data: users + } = await apiRequest.get(`/api/v1/groups/${groupSlug}/users`); + + return users; + } + }); +}; \ 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 new file mode 100644 index 000000000..5b06d32a7 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx @@ -0,0 +1,130 @@ +import { faUsers } from "@fortawesome/free-solid-svg-icons"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + EmptyState, + Modal, + ModalContent, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + useCreateGroupUserMembership, + useDeleteGroupUserMembership, + useGetGroupUserMemberships} from "@app/hooks/api"; +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; +}; + +export const OrgGroupMembersModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + + const popUpData = popUp?.groupMembers?.data as { + slug: string; + }; + + const { data: users, isLoading } = useGetGroupUserMemberships(popUpData?.slug ?? ""); + const { mutateAsync: assignMutateAsync } = useCreateGroupUserMembership(); + const { mutateAsync: unassignMutateAsync } = useDeleteGroupUserMembership(); + + const handleAssignment = async (username: string, assign: boolean) => { + try { + if (!popUpData?.slug) return; + + if (assign) { + await assignMutateAsync({ + username, + slug: popUpData.slug + }); + } else { + await unassignMutateAsync({ + username, + slug: popUpData.slug + }); + } + + createNotification({ + text: `Successfully ${assign ? "assigned" : "removed "} user ${assign ? "to" : "from"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("groupMembers", isOpen); + }} + > + + + + + + + + + + + {isLoading && } + {!isLoading && users?.map(({ + id, + firstName, + lastName, + username, + isPartOfGroup + }) => { + return ( + + + + + ); + })} + +
UserStatus
+

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

+

{username}

+
+ +
+ {!isLoading && !users?.length && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx index 7945a4d25..883a59eb4 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx @@ -126,10 +126,7 @@ export const OrgGroupModal = ({ reset(); }} > - +
{ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "group", + "groupMembers", "deleteGroup", "upgradePlan" ] as const); @@ -92,6 +94,11 @@ export const OrgGroupsSection = () => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> + , data?: { groupId?: string; @@ -143,6 +143,29 @@ export const OrgGroupsTable = ({
+ + {(isAllowed) => ( + + { + handlePopUpOpen("groupMembers", { + slug + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + + )} +