Finish basic CRUD groups

This commit is contained in:
Tuan Dang
2024-03-19 10:20:51 -07:00
parent cd192ee228
commit efc186ae6c
41 changed files with 1066 additions and 11 deletions
@@ -16,6 +16,7 @@ export enum OrgPermissionSubjects {
Scim = "scim",
Sso = "sso",
Ldap = "ldap",
Groups = "groups",
Billing = "billing",
SecretScanning = "secret-scanning",
Identity = "identity"
@@ -31,6 +32,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
| [OrgPermissionActions, OrgPermissionSubjects.Groups]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
+4
View File
@@ -0,0 +1,4 @@
export {
useCreateGroup,
useDeleteGroup,
useUpdateGroup} from "./mutations";
@@ -0,0 +1,87 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries";
import { TGroup } from "./types";
export const useCreateGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
name,
slug,
organizationId,
role
}: {
name: string;
slug: string;
organizationId: string;
role?: string;
}) => {
const {
data: group
} = await apiRequest.post<TGroup>("/api/v1/groups", {
name,
slug,
organizationId,
role
});
return group;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries(organizationKeys.getOrgGroups(organizationId));
}
});
};
export const useUpdateGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
currentSlug,
name,
slug,
role
}: {
currentSlug: string;
name?: string;
slug?: string;
role?: string;
}) => {
const {
data: group
} = await apiRequest.patch<TGroup>(`/api/v1/groups/${currentSlug}`, {
name,
slug,
role
});
return group;
},
onSuccess: ({ orgId }) => {
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
}
});
};
export const useDeleteGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
slug
}: {
slug: string;
}) => {
const {
data: group
} = await apiRequest.delete<TGroup>(`/api/v1/groups/${slug}`);
return group;
},
onSuccess: ({ orgId }) => {
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
}
});
};
+9
View File
@@ -0,0 +1,9 @@
export type TGroup = {
id: string;
name: string;
slug: string;
orgId: string;
createAt: string;
updatedAt: string;
role: string;
};
+1
View File
@@ -3,6 +3,7 @@ export * from "./apiKeys";
export * from "./auditLogs";
export * from "./auth";
export * from "./bots";
export * from "./groups";
export * from "./identities";
export * from "./incidentContacts";
export * from "./integrationAuth";
+3 -3
View File
@@ -7,6 +7,7 @@ export {
useDeleteOrgPmtMethod,
useDeleteOrgTaxId,
useGetIdentityMembershipOrgs,
useGetOrganizationGroups,
useGetOrganizations,
useGetOrgBillingDetails,
useGetOrgInvoices,
@@ -17,6 +18,5 @@ export {
useGetOrgPmtMethods,
useGetOrgTaxIds,
useGetOrgTrialUrl,
useUpdateOrg,
useUpdateOrgBillingDetails
} from "./queries";
useUpdateOrg,
useUpdateOrgBillingDetails} from "./queries";
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TGroup } from "../groups/types";
import { IdentityMembershipOrg } from "../identities/types";
import {
BillingDetails,
@@ -27,8 +28,8 @@ export const organizationKeys = {
getOrgTaxIds: (orgId: string) => [{ orgId }, "organization-tax-ids"] as const,
getOrgInvoices: (orgId: string) => [{ orgId }, "organization-invoices"] as const,
getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const,
getOrgIdentityMemberships: (orgId: string) =>
[{ orgId }, "organization-identity-memberships"] as const
getOrgIdentityMemberships: (orgId: string) => [{ orgId }, "organization-identity-memberships"] as const,
getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const
};
export const fetchOrganizations = async () => {
@@ -404,3 +405,16 @@ export const useDeleteOrgById = () => {
}
});
};
export const useGetOrganizationGroups = (organizationId: string) => {
return useQuery({
queryKey: organizationKeys.getOrgGroups(organizationId),
queryFn: async () => {
const {
data: { groups }
} = await apiRequest.get<{ groups: TGroup[] }>(`/api/v1/organization/${organizationId}/groups`);
return groups;
}
});
};
@@ -20,6 +20,7 @@ export type SubscriptionPlan = {
samlSSO: boolean;
scim: boolean;
ldap: boolean;
groups: boolean;
status:
| "incomplete"
| "incomplete_expired"
@@ -3,10 +3,16 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
import {
OrgGroupsTab,
OrgIdentityTab,
OrgMembersTab,
OrgRoleTabSection
} from "./components";
enum TabSections {
Member = "members",
Groups = "groups",
Roles = "roles",
Identities = "identities"
}
@@ -20,6 +26,7 @@ export const MembersPage = withPermission(
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.Groups}>Groups</Tab>
<Tab value={TabSections.Identities}>
<div className="flex items-center">
<p>Machine Identities</p>
@@ -33,6 +40,9 @@ export const MembersPage = withPermission(
<TabPanel value={TabSections.Member}>
<OrgMembersTab />
</TabPanel>
<TabPanel value={TabSections.Groups}>
<OrgGroupsTab />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<OrgIdentityTab />
</TabPanel>
@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { OrgGroupsSection } from "./components";
export const OrgGroupsTab = () => {
return (
<motion.div
key="panel-org-groups"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgGroupsSection />
</motion.div>
);
}
@@ -0,0 +1,214 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useOrganization } from "@app/context";
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(1, "Slug cannot be empty").max(36, "Slug must be 36 characters or fewer"),
role: z.string()
});
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
type Props = {
popUp: UsePopUpState<["group"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["group"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
};
export const OrgGroupModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { currentOrg } = useOrganization();
const { createNotification } = useNotificationContext();
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?.group?.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?.slug ?? group.role
});
} else {
reset({
name: "",
slug: "",
role: roles[0].slug
});
}
}, [popUp?.group?.data, roles]);
const onGroupModalSubmit = async ({
name,
slug,
role
}: TGroupFormData) => {
try {
if (!currentOrg?.id) return;
const group = popUp?.group?.data as {
groupId: string;
name: string;
slug: string;
};
if (group) {
await updateMutateAsync({
currentSlug: group.slug,
name,
slug,
role: role || undefined
});
} else {
await createMutateAsync({
name,
slug,
organizationId: currentOrg.id,
role: role || undefined
});
}
handlePopUpToggle("group", false);
createNotification({
text: `Successfully ${popUp?.group?.data ? "updated" : "created"} group`,
type: "success"
});
} catch (err) {
createNotification({
text: `Failed to ${popUp?.group?.data ? "updated" : "created"} group`,
type: "error"
});
}
}
return (
<Modal
isOpen={popUp?.group?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("group", isOpen);
reset();
}}
>
<ModalContent
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
subTitle="Manage users more easily with groups"
>
<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"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.group?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`org-group-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!popUp?.group?.data ? "Create" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("group")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,115 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
UpgradePlanModal
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useSubscription
} from "@app/context";
import { useDeleteGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { OrgGroupModal } from "./OrgGroupModal";
import { OrgGroupsTable } from "./OrgGroupsTable";
export const OrgGroupsSection = () => {
const { createNotification } = useNotificationContext();
const { subscription } = useSubscription();
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"group",
"deleteGroup",
"upgradePlan"
] as const);
const handleAddGroupModal = () => {
if (!subscription?.groups) {
handlePopUpOpen("upgradePlan", {
description: "You can manage users more efficiently with groups if you upgrade your Infisical plan."
});
} else {
handlePopUpOpen("group");
}
}
const onDeleteGroupSubmit = async ({
name,
slug
}: {
name: string;
slug: string;
}) => {
try {
await deleteMutateAsync({
slug
});
createNotification({
text: `Successfully deleted the group named ${name}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to delete the group named ${name}`,
type: "error"
});
}
handlePopUpClose("deleteGroup");
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}
isDisabled={!isAllowed}
>
Create Group
</Button>
)}
</OrgPermissionCan>
</div>
<OrgGroupsTable
handlePopUpOpen={handlePopUpOpen}
/>
<OrgGroupModal
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; slug: string })
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
}
@@ -0,0 +1,177 @@
import { useState } from "react";
// import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { faMagnifyingGlass, faPencil, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
// Button,
EmptyState,
IconButton,
Input,
// Select,
// SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization} from "@app/context";
import {
useGetOrganizationGroups,
// useGetOrgRoles
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["group", "deleteGroup"]
>,
data?: {
groupId?: string;
name?: string;
slug?: string;
}
) => void;
};
export const OrgGroupsTable = ({
handlePopUpOpen
}: Props) => {
// const { createNotification } = useNotificationContext();
const [searchGroupsFilter, setSearchGroupsFilter] = useState("");
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { isLoading, data: groups } = useGetOrganizationGroups(orgId);
// const { data: roles } = useGetOrgRoles(orgId);
console.log("OrgGroupsTable groups: ", groups);
console.log("OrgGroupsTable roles: ", groups);
// const handleChangeRole = ({
// groupId,
// role
// }: {
// groupId: string;
// role: string;
// }) => {
// try {
// // TODO
// createNotification({
// text: "Successfully updated group role",
// type: "success"
// });
// } catch (err) {
// console.error(err);
// createNotification({
// text: "Failed to update group role",
// type: "error"
// });
// }
// }
return (
<div>
<Input
value={searchGroupsFilter}
onChange={(e) => setSearchGroupsFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
{!isLoading && groups?.map(({ id, name, slug }) => {
return (
<Tr className="h-10" key={`org-group-${id}`}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>N/A</Td>
<Td>
<div className="flex items-center justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<Tooltip content="Edit group">
<IconButton
onClick={async () => {
handlePopUpOpen("group", {
groupId: id,
name,
slug
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => (
<Tooltip content="Delete group">
<IconButton
onClick={() => {
console.log("Delete group");
handlePopUpOpen("deleteGroup", {
slug,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
)}
</OrgPermissionCan>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{groups?.length === 0 && (
<EmptyState title="No groups found" icon={faUsers} />
)}
</TableContainer>
</div>
);
}
@@ -0,0 +1 @@
export { OrgGroupsSection } from "./OrgGroupsSection";
@@ -0,0 +1 @@
export { OrgGroupsSection } from "./OrgGroupsSection";
@@ -0,0 +1 @@
export { OrgGroupsTab } from "./OrgGroupsTab";
@@ -140,7 +140,7 @@ export const IdentityModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Pro
const error = err as any;
const text =
error?.response?.data?.message ??
`Failed to ${popUp?.identity?.data ? "updated" : "created"} identity`;
`Failed to ${popUp?.identity?.data ? "update" : "create"} identity`;
createNotification({
text,
@@ -52,6 +52,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const { data, isLoading } = useGetIdentityMembershipOrgs(orgId);
const { data: roles } = useGetOrgRoles(orgId);
console.log("IdentityTable data: ", data);
console.log("IdentityTable roles: ", roles);
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try {
@@ -5,7 +5,7 @@ import { OrgMembersSection } from "./components";
export const OrgMembersTab = () => {
return (
<motion.div
key="panel-service-token"
key="panel-org-members"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
@@ -62,6 +62,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
const { data: serverDetails } = useFetchServerStatus();
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
console.log("OrgGroupsTable members: ", members);
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
const { mutateAsync: updateUserOrgRole } = useUpdateOrgUserRole();
@@ -263,7 +265,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
</TBody>
</Table>
{!isLoading && filterdUser?.length === 0 && (
<EmptyState title="No project members found" icon={faUsers} />
<EmptyState title="No organization members found" icon={faUsers} />
)}
</TableContainer>
</div>
@@ -1,3 +1,4 @@
export { OrgGroupsTab } from "./OrgGroupsTab";
export { OrgIdentityTab } from "./OrgIdentityTab";
export { OrgMembersTab } from "./OrgMembersTab";
export { OrgRoleTabSection } from "./OrgRoleTabSection";