improvement: Better group member management (#2851)

* improvement: Better org member management
This commit is contained in:
McPizza
2024-12-10 14:10:14 +01:00
committed by GitHub
parent 7f0d27e3dc
commit e32716c258
22 changed files with 974 additions and 96 deletions
+5 -2
View File
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; 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 { GROUPS } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas"; import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; 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), 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), 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), 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: { response: {
200: z.object({ 200: z.object({
@@ -164,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
}) })
.merge( .merge(
z.object({ z.object({
isPartOfGroup: z.boolean() isPartOfGroup: z.boolean(),
joinedGroupAt: z.date().nullable()
}) })
) )
.array(), .array(),
+28 -3
View File
@@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
import { EFilterReturnedUsers } from "./group-types";
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>; export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
export const groupDALFactory = (db: TDbClient) => { export const groupDALFactory = (db: TDbClient) => {
@@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => {
offset = 0, offset = 0,
limit, limit,
username, // depreciated in favor of search username, // depreciated in favor of search
search search,
filter
}: { }: {
orgId: string; orgId: string;
groupId: string; groupId: string;
@@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => {
limit?: number; limit?: number;
username?: string; username?: string;
search?: string; search?: string;
filter?: EFilterReturnedUsers;
}) => { }) => {
try { try {
const query = db const query = db
@@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => {
.select( .select(
db.ref("id").withSchema(TableName.OrgMembership), db.ref("id").withSchema(TableName.OrgMembership),
db.ref("groupId").withSchema(TableName.UserGroupMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership),
db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"),
db.ref("email").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users),
db.ref("firstName").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}%`); 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; const members = await query;
return { return {
members: members.map( members: members.map(
({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ ({
email,
username: memberUsername,
firstName,
lastName,
userId,
groupId: memberGroupId,
joinedGroupAt
}) => ({
id: userId, id: userId,
email, email,
username: memberUsername, username: memberUsername,
firstName, firstName,
lastName, lastName,
isPartOfGroup: !!memberGroupId isPartOfGroup: !!memberGroupId,
joinedGroupAt
}) })
), ),
// @ts-expect-error col select is raw and not strongly typed // @ts-expect-error col select is raw and not strongly typed
@@ -222,7 +222,8 @@ export const groupServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
search search,
filter
}: TListGroupUsersDTO) => { }: TListGroupUsersDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
@@ -251,7 +252,8 @@ export const groupServiceFactory = ({
offset, offset,
limit, limit,
username, username,
search search,
filter
}); });
return { users: members, totalCount }; return { users: members, totalCount };
@@ -283,8 +285,8 @@ export const groupServiceFactory = ({
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
// check if user has broader or equal to privileges than group // check if user has broader or equal to privileges than group
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission);
if (!hasRequiredPriviledges) if (!hasRequiredPrivileges)
throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" });
const user = await userDAL.findOne({ username }); const user = await userDAL.findOne({ username });
@@ -338,8 +340,8 @@ export const groupServiceFactory = ({
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
// check if user has broader or equal to privileges than group // check if user has broader or equal to privileges than group
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission);
if (!hasRequiredPriviledges) if (!hasRequiredPrivileges)
throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" });
const user = await userDAL.findOne({ username }); const user = await userDAL.findOne({ username });
@@ -39,6 +39,7 @@ export type TListGroupUsersDTO = {
limit: number; limit: number;
username?: string; username?: string;
search?: string; search?: string;
filter?: EFilterReturnedUsers;
} & TGenericPermission; } & TGenericPermission;
export type TAddUserToGroupDTO = { export type TAddUserToGroupDTO = {
@@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = {
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">; projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx?: Knex; tx?: Knex;
}; };
export enum EFilterReturnedUsers {
EXISTING_MEMBERS = "existingMembers",
NON_MEMBERS = "nonMembers"
}
+3 -1
View File
@@ -19,7 +19,9 @@ export const GROUPS = {
offset: "The offset to start from. If you enter 10, it will start from the 10th user.", offset: "The offset to start from. If you enter 10, it will start from the 10th user.",
limit: "The number of users to return.", limit: "The number of users to return.",
username: "The username to search for.", 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: { ADD_USER: {
id: "The ID of the group to add the user to.", id: "The ID of the group to add the user to.",
+7 -8
View File
@@ -1,9 +1,8 @@
export { export {
useAddUserToGroup, useAddUserToGroup,
useCreateGroup, useCreateGroup,
useDeleteGroup, useDeleteGroup,
useRemoveUserFromGroup, useRemoveUserFromGroup,
useUpdateGroup} from "./mutations"; useUpdateGroup
export { } from "./mutations";
useListGroupUsers export { useGetGroupById, useListGroupUsers } from "./queries";
} from "./queries";
+4 -2
View File
@@ -56,8 +56,9 @@ export const useUpdateGroup = () => {
return group; return group;
}, },
onSuccess: ({ orgId }) => { onSuccess: ({ orgId, id: groupId }) => {
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
queryClient.invalidateQueries(groupKeys.getGroupById(groupId));
} }
}); });
}; };
@@ -70,8 +71,9 @@ export const useDeleteGroup = () => {
return group; return group;
}, },
onSuccess: ({ orgId }) => { onSuccess: ({ orgId, id: groupId }) => {
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
queryClient.invalidateQueries(groupKeys.getGroupById(groupId));
} }
}); });
}; };
+25 -13
View File
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types";
export const groupKeys = { export const groupKeys = {
getGroupById: (groupId: string) => [{ groupId }, "group"] as const,
allGroupUserMemberships: () => ["group-user-memberships"] as const, allGroupUserMemberships: () => ["group-user-memberships"] as const,
forGroupUserMemberships: (slug: string) => forGroupUserMemberships: (slug: string) =>
[...groupKeys.allGroupUserMemberships(), slug] as const, [...groupKeys.allGroupUserMemberships(), slug] as const,
@@ -10,22 +13,27 @@ export const groupKeys = {
slug, slug,
offset, offset,
limit, limit,
search search,
filter
}: { }: {
slug: string; slug: string;
offset: number; offset: number;
limit: number; limit: number;
search: string; search: string;
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const filter?: EFilterReturnedUsers;
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const
}; };
type TUser = { export const useGetGroupById = (groupId: string) => {
id: string; return useQuery({
email: string; enabled: Boolean(groupId),
username: string; queryKey: groupKeys.getGroupById(groupId),
firstName: string; queryFn: async () => {
lastName: string; const { data } = await apiRequest.get<TGroup>(`/api/v1/groups/${groupId}`);
isPartOfGroup: boolean;
return { group: data };
}
});
}; };
export const useListGroupUsers = ({ export const useListGroupUsers = ({
@@ -33,20 +41,23 @@ export const useListGroupUsers = ({
groupSlug, groupSlug,
offset = 0, offset = 0,
limit = 10, limit = 10,
search search,
filter
}: { }: {
id: string; id: string;
groupSlug: string; groupSlug: string;
offset: number; offset: number;
limit: number; limit: number;
search: string; search: string;
filter?: EFilterReturnedUsers;
}) => { }) => {
return useQuery({ return useQuery({
queryKey: groupKeys.specificGroupUserMemberships({ queryKey: groupKeys.specificGroupUserMemberships({
slug: groupSlug, slug: groupSlug,
offset, offset,
limit, limit,
search search,
filter
}), }),
enabled: Boolean(groupSlug), enabled: Boolean(groupSlug),
keepPreviousData: true, keepPreviousData: true,
@@ -54,10 +65,11 @@ export const useListGroupUsers = ({
const params = new URLSearchParams({ const params = new URLSearchParams({
offset: String(offset), offset: String(offset),
limit: String(limit), 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`, `/api/v1/groups/${id}/users`,
{ {
params params
+16 -1
View File
@@ -11,7 +11,7 @@ export type TGroup = {
name: string; name: string;
slug: string; slug: string;
orgId: string; orgId: string;
createAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
role: string; role: string;
}; };
@@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = {
slug: string; slug: string;
orgId: 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"
}
@@ -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 (
<>
<Head>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<GroupPage />
</>
);
}
Group.requireAuth = true;
@@ -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 <Spinner size="sm" className="mt-2 ml-2" />;
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`);
}}
className="mb-4"
>
Groups
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">{data.group.name}</p>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("groupCreateUpdate", {
groupId,
name: data.group.name,
slug: data.group.slug,
role: data.group.role
});
}}
disabled={!isAllowed}
>
Edit Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("deleteGroup", {
id: groupId,
name: data.group.name
});
}}
disabled={!isAllowed}
>
Delete Group
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex">
<div className="mr-4 w-96">
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
</div>
</div>
)}
<GroupCreateUpdateModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure want to delete the group named ${
(popUp?.deleteGroup?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups }
);
@@ -22,21 +22,22 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { useDebounce, useResetPageHelper } from "@app/hooks"; 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"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
popUp: UsePopUpState<["groupMembers"]>; popUp: UsePopUpState<["addGroupMembers"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; 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 [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [searchMemberFilter, setSearchMemberFilter] = useState("");
const [debouncedSearch] = useDebounce(searchMemberFilter); const [debouncedSearch] = useDebounce(searchMemberFilter);
const popUpData = popUp?.groupMembers?.data as { const popUpData = popUp?.addGroupMembers?.data as {
groupId: string; groupId: string;
slug: string; slug: string;
}; };
@@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
groupSlug: popUpData?.slug, groupSlug: popUpData?.slug,
offset, offset,
limit: perPage, limit: perPage,
search: debouncedSearch search: debouncedSearch,
filter: EFilterReturnedUsers.NON_MEMBERS
}); });
const { totalCount = 0 } = data ?? {}; const { totalCount = 0 } = data ?? {};
@@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
setPage setPage
}); });
const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => { const handleAddMember = async (username: string) => {
try { try {
if (!popUpData?.slug) return; if (!popUpData?.slug) {
createNotification({
if (assign) { text: "Some data is missing, please refresh the page and try again",
await assignMutateAsync({ type: "error"
groupId: popUpData.groupId,
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
groupId: popUpData.groupId,
username,
slug: popUpData.slug
}); });
return;
} }
await addUserToGroupMutateAsync({
groupId: popUpData.groupId,
username,
slug: popUpData.slug
});
createNotification({ createNotification({
text: `Successfully ${assign ? "assigned" : "removed"} user ${ text: "Successfully assigned user to the group",
assign ? "to" : "from"
} group`,
type: "success" type: "success"
}); });
} catch (err) { } catch (err) {
createNotification({ createNotification({
text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`, text: "Failed to assign user to the group",
type: "error" type: "error"
}); });
} }
@@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
return ( return (
<Modal <Modal
isOpen={popUp?.groupMembers?.isOpen} isOpen={popUp?.addGroupMembers?.isOpen}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("groupMembers", isOpen); handlePopUpToggle("addGroupMembers", isOpen);
}} }}
> >
<ModalContent title="Manage Group Members"> <ModalContent title="Add Group Members">
<Input <Input
value={searchMemberFilter} value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)} onChange={(e) => setSearchMemberFilter(e.target.value)}
@@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
<TBody> <TBody>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />} {isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
{!isLoading && {!isLoading &&
data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => { data?.users?.map(({ id, firstName, lastName, username }) => {
return ( return (
<Tr className="items-center" key={`group-user-${id}`}> <Tr className="items-center" key={`group-user-${id}`}>
<Td> <Td>
@@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
type="submit" type="submit"
onClick={() => handleAssignment(username, !isPartOfGroup)} onClick={() => handleAddMember(username)}
> >
{isPartOfGroup ? "Unassign" : "Assign"} Assign
</Button> </Button>
); );
}} }}
@@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
)} )}
{!isLoading && !data?.users?.length && ( {!isLoading && !data?.users?.length && (
<EmptyState <EmptyState
title={debouncedSearch ? "No users match search" : "No users found"} title={
debouncedSearch ? "No users match search" : "All users are already in the group"
}
icon={faUsers} icon={faUsers}
/> />
)} )}
@@ -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<typeof GroupFormSchema>;
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<TGroupFormData>({
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 (
<Modal
isOpen={popUp?.groupCreateUpdate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("groupCreateUpdate", isOpen);
reset();
}}
>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.groupCreateUpdate?.data ? "Update" : "Create"} Group`}
>
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="Engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl label="Slug" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
options={roles}
placeholder="Select role..."
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!popUp?.groupCreateUpdate?.data ? "Create" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("groupCreateUpdate")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -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 <Spinner size="sm" className="mt-2 ml-2" />;
return data ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Group Details</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<Tooltip content="Edit Group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit group button"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("groupCreateUpdate", {
groupId,
name: data.group.name,
slug: data.group.slug,
role: data.group.role
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Group ID</p>
<div className="group flex items-center gap-2">
<p className="text-sm text-mineshaft-300">{data.group.id}</p>
<CopyButton value={data.group.id} name="Group ID" size="xs" variant="plain" />
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.group.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
<div className="group flex items-center gap-2">
<p className="text-sm text-mineshaft-300">{data.group.slug}</p>
<CopyButton value={data.group.slug} name="Slug" size="xs" variant="plain" />
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.group.role}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Created At</p>
<p className="text-sm text-mineshaft-300">
{new Date(data.group.createdAt).toLocaleString()}
</p>
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<p className="text-mineshaft-300">Group data not found</p>
</div>
</div>
);
};
@@ -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 (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Group Members</h3>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
<div className="py-4">
<GroupMembersTable
groupId={groupId}
groupSlug={groupSlug}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeMemberFromGroup.isOpen}
title={`Are you sure want to remove ${
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
} from the group?`}
onChange={(isOpen) => handlePopUpToggle("removeMemberFromGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const userData = popUp?.removeMemberFromGroup?.data as {
username: string;
id: string;
};
return handleRemoveUserFromGroup(userData.username);
}}
/>
</div>
);
};
@@ -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 (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Email</Th>
<Th>Added On</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isLoading &&
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
return (
<GroupMembershipRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships?.users.length
? "No users match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.users.length ? faSearch : faFolder}
/>
)}
{!groupMemberships?.users.length && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<div className="mb-4 flex items-center justify-center">
<Button
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
Add members
</Button>
</div>
)}
</OrgPermissionCan>
)}
</TableContainer>
</div>
);
};
@@ -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 (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
</Td>
<Td>
<p>{email}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td className="justify-end">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<Tooltip content="Remove user from group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="Remove user from group"
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
variant="plain"
colorSchema="danger"
>
<FontAwesomeIcon icon={faUserMinus} className="cursor-pointer" />
</IconButton>
</Tooltip>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
};
@@ -0,0 +1 @@
export { GroupMembersSection } from "./GroupMembersSection";
@@ -0,0 +1 @@
export { GroupDetailsSection } from "./GroupDetailsSection";
@@ -0,0 +1 @@
export { GroupPage } from "./GroupPage";
@@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a
import { useDeleteGroup } from "@app/hooks/api"; import { useDeleteGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { OrgGroupMembersModal } from "./OrgGroupMembersModal";
import { OrgGroupModal } from "./OrgGroupModal"; import { OrgGroupModal } from "./OrgGroupModal";
import { OrgGroupsTable } from "./OrgGroupsTable"; import { OrgGroupsTable } from "./OrgGroupsTable";
@@ -78,7 +77,6 @@ export const OrgGroupsSection = () => {
handlePopUpClose={handlePopUpClose} handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle} handlePopUpToggle={handlePopUpToggle}
/> />
<OrgGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteGroup.isOpen} isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure want to delete the group named ${ title={`Are you sure want to delete the group named ${
@@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useRouter } from "next/router";
import { import {
faArrowDown, faArrowDown,
faArrowUp, faArrowUp,
@@ -61,6 +62,7 @@ enum GroupsOrderBy {
} }
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId); const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
@@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
.slice(offset, perPage * page) .slice(offset, perPage * page)
.map(({ id, name, slug, role, customRole }) => { .map(({ id, name, slug, role, customRole }) => {
return ( return (
<Tr className="h-10" key={`org-group-${id}`}> <Tr
onClick={() => router.push(`/org/${orgId}/groups/${id}`)}
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`org-group-${id}`}
>
<Td>{name}</Td> <Td>{name}</Td>
<Td>{slug}</Td> <Td>{slug}</Td>
<Td> <Td>
@@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
</DropdownMenuItem> </DropdownMenuItem>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Edit} I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity} a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("groupMembers", {
groupId: id,
slug
});
}}
disabled={!isAllowed}
>
Manage Users
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Identity}
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
@@ -324,6 +307,23 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</OrgPermissionCan> </OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => router.push(`/org/${orgId}/groups/${id}`)}
disabled={!isAllowed}
>
Manage Members
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan <OrgPermissionCan
I={OrgPermissionActions.Delete} I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups} a={OrgPermissionSubjects.Groups}