diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index b2f1762d1..67f955ecb 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { GROUPS } from "@app/lib/api-docs"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -151,7 +152,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search) + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -164,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - isPartOfGroup: z.boolean() + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() }) ) .array(), diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 5e25f6113..fc38a2a9b 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; +import { EFilterReturnedUsers } from "./group-types"; + export type TGroupDALFactory = ReturnType; export const groupDALFactory = (db: TDbClient) => { @@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => { offset = 0, limit, username, // depreciated in favor of search - search + search, + filter }: { orgId: string; groupId: string; @@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; }) => { try { const query = db @@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => { .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -111,17 +116,37 @@ export const groupDALFactory = (db: TDbClient) => { void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); } + switch (filter) { + case EFilterReturnedUsers.EXISTING_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + break; + case EFilterReturnedUsers.NON_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + const members = await query; return { members: members.map( - ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + ({ + email, + username: memberUsername, + firstName, + lastName, + userId, + groupId: memberGroupId, + joinedGroupAt + }) => ({ id: userId, email, username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId + isPartOfGroup: !!memberGroupId, + joinedGroupAt }) ), // @ts-expect-error col select is raw and not strongly typed diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7e7139a6b..68c48524b 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -222,7 +222,8 @@ export const groupServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - search + search, + filter }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); @@ -251,7 +252,8 @@ export const groupServiceFactory = ({ offset, limit, username, - search + search, + filter }); return { users: members, totalCount }; @@ -283,8 +285,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); const user = await userDAL.findOne({ username }); @@ -338,8 +340,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); const user = await userDAL.findOne({ username }); diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index a6eb4782b..9424075ca 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -39,6 +39,7 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; } & TGenericPermission; export type TAddUserToGroupDTO = { @@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { projectBotDAL: Pick; tx?: Knex; }; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 518654da1..711837326 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -19,7 +19,9 @@ export const GROUPS = { offset: "The offset to start from. If you enter 10, it will start from the 10th user.", limit: "The number of users to return.", username: "The username to search for.", - search: "The text string that user email or name will be filtered by." + search: "The text string that user email or name will be filtered by.", + filterUsers: + "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, ADD_USER: { id: "The ID of the group to add the user to.", diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 26b38d3a4..c23a55832 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,9 +1,8 @@ export { - useAddUserToGroup, - useCreateGroup, - useDeleteGroup, - useRemoveUserFromGroup, - useUpdateGroup} from "./mutations"; -export { - useListGroupUsers -} from "./queries"; \ No newline at end of file + useAddUserToGroup, + useCreateGroup, + useDeleteGroup, + useRemoveUserFromGroup, + useUpdateGroup +} from "./mutations"; +export { useGetGroupById, useListGroupUsers } from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 445ae10bc..2f5c5984c 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -56,8 +56,9 @@ export const useUpdateGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; @@ -70,8 +71,9 @@ export const useDeleteGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index b239b0a61..dc3791db7 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types"; + export const groupKeys = { + getGroupById: (groupId: string) => [{ groupId }, "group"] as const, allGroupUserMemberships: () => ["group-user-memberships"] as const, forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, @@ -10,22 +13,27 @@ export const groupKeys = { slug, offset, limit, - search + search, + filter }: { slug: string; offset: number; limit: number; search: string; - }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const + filter?: EFilterReturnedUsers; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const }; -type TUser = { - id: string; - email: string; - username: string; - firstName: string; - lastName: string; - isPartOfGroup: boolean; +export const useGetGroupById = (groupId: string) => { + return useQuery({ + enabled: Boolean(groupId), + queryKey: groupKeys.getGroupById(groupId), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/groups/${groupId}`); + + return { group: data }; + } + }); }; export const useListGroupUsers = ({ @@ -33,20 +41,23 @@ export const useListGroupUsers = ({ groupSlug, offset = 0, limit = 10, - search + search, + filter }: { id: string; groupSlug: string; offset: number; limit: number; search: string; + filter?: EFilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ slug: groupSlug, offset, limit, - search + search, + filter }), enabled: Boolean(groupSlug), keepPreviousData: true, @@ -54,10 +65,11 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search + search, + ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>( + const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( `/api/v1/groups/${id}/users`, { params diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3f69b9a0e..6bc82b39e 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -11,7 +11,7 @@ export type TGroup = { name: string; slug: string; orgId: string; - createAt: string; + createdAt: string; updatedAt: string; role: string; }; @@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = { slug: string; orgId: string; }; + +export type TGroupUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; + joinedGroupAt: Date; +}; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx new file mode 100644 index 000000000..e193d9bd5 --- /dev/null +++ b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { GroupPage } from "@app/views/Org/GroupPage"; + +export default function Group() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +Group.requireAuth = true; diff --git a/frontend/src/views/Org/GroupPage/GroupPage.tsx b/frontend/src/views/Org/GroupPage/GroupPage.tsx new file mode 100644 index 000000000..acde15760 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/GroupPage.tsx @@ -0,0 +1,175 @@ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteGroup } from "@app/hooks/api"; +import { useGetGroupById } from "@app/hooks/api/groups/queries"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { TabSections } from "@app/views/Org/Types"; + +import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal"; +import { GroupMembersSection } from "./components/GroupMembersSection"; +import { GroupDetailsSection } from "./components"; + +export const GroupPage = withPermission( + () => { + const router = useRouter(); + const groupId = router.query.groupId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data, isLoading } = useGetGroupById(groupId); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "groupCreateUpdate", + "deleteGroup", + "upgradePlan" + ] as const); + + const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => { + try { + await deleteMutateAsync({ + id + }); + createNotification({ + text: `Successfully deleted the ${name} group`, + type: "success" + }); + router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete the ${name} group`, + type: "error" + }); + } + + handlePopUpClose("deleteGroup"); + }; + + if (isLoading) return ; + + return ( +
+ {data && ( +
+ +
+

{data.group.name}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + disabled={!isAllowed} + > + Edit Group + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + id: groupId, + name: data.group.name + }); + }} + disabled={!isAllowed} + > + Delete Group + + )} + + +
+
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx similarity index 71% rename from frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx rename to frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx index e7f38318a..ab81aa445 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx @@ -22,21 +22,22 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["groupMembers"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; + popUp: UsePopUpState<["addGroupMembers"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; }; -export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { +export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); - const popUpData = popUp?.groupMembers?.data as { + const popUpData = popUp?.addGroupMembers?.data as { groupId: string; slug: string; }; @@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { groupSlug: popUpData?.slug, offset, limit: perPage, - search: debouncedSearch + search: debouncedSearch, + filter: EFilterReturnedUsers.NON_MEMBERS }); const { totalCount = 0 } = data ?? {}; @@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { setPage }); - const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); - const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - const handleAssignment = async (username: string, assign: boolean) => { + const handleAddMember = async (username: string) => { try { - if (!popUpData?.slug) return; - - if (assign) { - await assignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - } else { - await unassignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug + if (!popUpData?.slug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" }); + return; } + await addUserToGroupMutateAsync({ + groupId: popUpData.groupId, + username, + slug: popUpData.slug + }); + createNotification({ - text: `Successfully ${assign ? "assigned" : "removed"} user ${ - assign ? "to" : "from" - } group`, + text: "Successfully assigned user to the group", type: "success" }); } catch (err) { createNotification({ - text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`, + text: "Failed to assign user to the group", type: "error" }); } @@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("groupMembers", isOpen); + handlePopUpToggle("addGroupMembers", isOpen); }} > - + setSearchMemberFilter(e.target.value)} @@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { {isLoading && } {!isLoading && - data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => { + data?.users?.map(({ id, firstName, lastName, username }) => { return ( @@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { colorSchema="primary" variant="outline_bg" type="submit" - onClick={() => handleAssignment(username, !isPartOfGroup)} + onClick={() => handleAddMember(username)} > - {isPartOfGroup ? "Unassign" : "Assign"} + Assign ); }} @@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { )} {!isLoading && !data?.users?.length && ( )} diff --git a/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx new file mode 100644 index 000000000..39187f3cb --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx @@ -0,0 +1,192 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; +import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const GroupFormSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), + slug: z + .string() + .min(5, "Slug must be at least 5 characters long") + .max(36, "Slug must be 36 characters or fewer"), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type TGroupFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["groupCreateUpdate"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, + state?: boolean + ) => void; +}; + +export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { data: roles } = useGetOrgRoles(currentOrg?.id || ""); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(GroupFormSchema) + }); + + useEffect(() => { + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (group) { + reset({ + name: group.name, + slug: group.slug, + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) + }); + } else { + reset({ + name: "", + slug: "", + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + }); + } + }, [popUp?.groupCreateUpdate?.data, roles]); + + const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => { + try { + if (!currentOrg?.id) return; + + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + }; + + if (group) { + await updateMutateAsync({ + id: group.groupId, + name, + slug, + role: role.slug || undefined + }); + } else { + await createMutateAsync({ + name, + slug, + organizationId: currentOrg.id, + role: role.slug || undefined + }); + } + handlePopUpToggle("groupCreateUpdate", false); + reset(); + + createNotification({ + text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("groupCreateUpdate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx new file mode 100644 index 000000000..624cc7241 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx @@ -0,0 +1,88 @@ +import { faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetGroupById } from "@app/hooks/api/"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + groupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void; +}; + +export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => { + const { data, isLoading } = useGetGroupById(groupId); + + if (isLoading) return ; + + return data ? ( +
+
+

Group Details

+ + {(isAllowed) => { + return ( + + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + > + + + + ); + }} + +
+
+
+

Group ID

+
+

{data.group.id}

+ +
+
+
+

Name

+

{data.group.name}

+
+
+

Slug

+
+

{data.group.slug}

+ +
+
+
+

Organization Role

+

{data.group.role}

+
+
+

Created At

+

+ {new Date(data.group.createdAt).toLocaleString()} +

+
+
+
+ ) : ( +
+
+

Group data not found

+
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx new file mode 100644 index 000000000..08de6c724 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -0,0 +1,90 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddGroupMembersModal } from "../AddGroupMemberModal"; +import { GroupMembersTable } from "./GroupMembersTable"; + +type Props = { + groupId: string; + groupSlug: string; +}; + +export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addGroupMembers", + "removeMemberFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); + const handleRemoveUserFromGroup = async (username: string) => { + try { + await removeUserFromGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${username} from the group`, + type: "success" + }); + + handlePopUpToggle("removeMemberFromGroup", false); + } catch (err) { + createNotification({ + text: `Failed to remove user ${username} from the group`, + type: "error" + }); + } + }; + + return ( +
+
+

Group Members

+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
+
+ +
+ + handlePopUpToggle("removeMemberFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const userData = popUp?.removeMemberFromGroup?.data as { + username: string; + id: string; + }; + + return handleRemoveUserFromGroup(userData.username); + }} + /> +
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx new file mode 100644 index 000000000..2423fd6d5 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faFolder, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListGroupUsers } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupMembershipRow } from "./GroupMembershipRow"; + +type Props = { + groupId: string; + groupSlug: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>, + data?: {} + ) => void; +}; + +enum GroupMembersOrderBy { + Name = "name" +} + +export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 }); + + const { data: groupMemberships, isLoading } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: EFilterReturnedUsers.EXISTING_MEMBERS + }); + + const filteredGroupMemberships = useMemo(() => { + return groupMemberships && groupMemberships?.users + ? groupMemberships?.users + ?.filter((membership) => { + const userSearchString = `${membership.firstName && membership.firstName} ${ + membership.lastName && membership.lastName + } ${membership.email && membership.email} ${ + membership.username && membership.username + }`; + return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); + }) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + const membershipOneComparisonString = membershipOne.firstName + ? membershipOne.firstName + : membershipOne.email; + + const membershipTwoComparisonString = membershipTwo.firstName + ? membershipTwo.firstName + : membershipTwo.email; + + const comparison = membershipOneComparisonString + .toLowerCase() + .localeCompare(membershipTwoComparisonString.toLowerCase()); + + return comparison; + }) + : []; + }, [groupMemberships, orderDirection, search]); + + useResetPageHelper({ + totalCount: filteredGroupMemberships?.length, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && + filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
EmailAdded On +
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} + {!groupMemberships?.users.length && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx new file mode 100644 index 000000000..943a6574e --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx @@ -0,0 +1,53 @@ +import { faUserMinus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { TGroupUser } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + user: TGroupUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void; +}; + +export const GroupMembershipRow = ({ + user: { firstName, lastName, username, joinedGroupAt, email, id }, + handlePopUpOpen +}: Props) => { + return ( + + +

{`${firstName ?? "-"} ${lastName ?? ""}`}

+ + +

{email}

+ + + +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+ + + + {(isAllowed) => { + return ( + + handlePopUpOpen("removeMemberFromGroup", { username })} + variant="plain" + colorSchema="danger" + > + + + + ); + }} + + + + ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx new file mode 100644 index 000000000..70c696609 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx @@ -0,0 +1 @@ +export { GroupMembersSection } from "./GroupMembersSection"; diff --git a/frontend/src/views/Org/GroupPage/components/index.tsx b/frontend/src/views/Org/GroupPage/components/index.tsx new file mode 100644 index 000000000..003c47910 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/index.tsx @@ -0,0 +1 @@ +export { GroupDetailsSection } from "./GroupDetailsSection"; diff --git a/frontend/src/views/Org/GroupPage/index.tsx b/frontend/src/views/Org/GroupPage/index.tsx new file mode 100644 index 000000000..3dec23a1c --- /dev/null +++ b/frontend/src/views/Org/GroupPage/index.tsx @@ -0,0 +1 @@ +export { GroupPage } from "./GroupPage"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index f72adf61f..9c3949150 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { OrgGroupMembersModal } from "./OrgGroupMembersModal"; import { OrgGroupModal } from "./OrgGroupModal"; import { OrgGroupsTable } from "./OrgGroupsTable"; @@ -78,7 +77,6 @@ export const OrgGroupsSection = () => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> - { + const router = useRouter(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId); @@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { .slice(offset, perPage * page) .map(({ id, name, slug, role, customRole }) => { return ( - + router.push(`/org/${orgId}/groups/${id}`)} + className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + key={`org-group-${id}`} + > {name} {slug} @@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("groupMembers", { - groupId: id, - slug - }); - }} - disabled={!isAllowed} - > - Manage Users - - )} - - {(isAllowed) => ( { )} + + {(isAllowed) => ( + router.push(`/org/${orgId}/groups/${id}`)} + disabled={!isAllowed} + > + Manage Members + + )} +