diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 076b33e54..b63550d2c 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -118,7 +118,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { querystring: z.object({ searchTerm: z.string().default(""), offset: z.coerce.number().default(0), - limit: z.coerce.number().max(100).default(20) + limit: z.coerce.number().max(100).default(20), + // TODO: remove this once z.coerce.boolean() is supported + adminsOnly: z + .string() + .transform((val) => val === "true") + .default("false") }), response: { 200: z.object({ diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 166f73317..8efdfd7f9 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -271,12 +271,13 @@ export const superAdminServiceFactory = ({ return { token, user: userInfo, organization }; }; - const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => { + const getUsers = ({ offset, limit, searchTerm, adminsOnly }: TAdminGetUsersDTO) => { return userDAL.getUsersByFilter({ limit, offset, searchTerm, - sortBy: "username" + sortBy: "username", + adminsOnly }); }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 2d10941b4..d6de67e59 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -20,6 +20,7 @@ export type TAdminGetUsersDTO = { offset: number; limit: number; searchTerm: string; + adminsOnly: boolean; }; export enum LoginMethod { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 99f403e84..eba497f0f 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -23,15 +23,18 @@ export const userDALFactory = (db: TDbClient) => { limit, offset, searchTerm, - sortBy + sortBy, + adminsOnly }: { limit: number; offset: number; searchTerm: string; sortBy?: keyof TUsers; + adminsOnly: boolean; }) => { try { let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); + if (searchTerm) { query = query.where((qb) => { void qb @@ -42,6 +45,10 @@ export const userDALFactory = (db: TDbClient) => { }); } + if (adminsOnly) { + query = query.where("superAdmin", true); + } + if (sortBy) { query = query.orderBy(sortBy); } diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index e5d281fc4..80e3edc92 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -50,6 +50,7 @@ export type TUpdateAdminSlackConfigDTO = { export type AdminGetUsersFilters = { limit: number; searchTerm: string; + adminsOnly: boolean; }; export type AdminSlackConfig = { diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index 4a255acad..3cc104f48 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -30,10 +30,13 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, + Modal, + ModalContent, Tooltip } from "@app/components/v2"; import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; +import { isInfisicalCloud } from "@app/helpers/platform"; import { usePopUp, useToggle } from "@app/hooks"; import { useGetOrganizations, @@ -50,6 +53,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { MenuIconButton } from "../MenuIconButton"; +import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel"; const getPlan = (subscription: SubscriptionPlan) => { if (subscription.dynamicSecret) return "Enterprise Plan"; @@ -77,6 +81,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [ , "Email Support", "mailto:support@infisical.com" + ], + [ + , + "Instance Admins", + "server-admins" ] ]; @@ -89,6 +98,7 @@ export const MinimizedOrgSidebar = () => { const [openSupport, setOpenSupport] = useState(false); const [openUser, setOpenUser] = useState(false); const [openOrg, setOpenOrg] = useState(false); + const [showAdminsModal, setShowAdminsModal] = useState(false); const { user } = useUser(); const { mutateAsync } = useGetOrgTrialUrl(); @@ -410,21 +420,39 @@ export const MinimizedOrgSidebar = () => { side="right" className="p-1" > - {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => ( - - -
- {icon} -
{text}
-
-
-
- ))} + {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => { + if (url === "server-admins" && isInfisicalCloud()) { + return null; + } + return ( + + {url === "server-admins" ? ( + + ) : ( + +
+ {icon} +
{text}
+
+
+ )} +
+ ); + })} {envConfig.PLATFORM_VERSION && (
@@ -540,6 +568,13 @@ export const MinimizedOrgSidebar = () => {
+ + +
+ +
+
+
handlePopUpToggle("createOrg", false)} diff --git a/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx b/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx new file mode 100644 index 000000000..c834daad8 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/ServerAdminsPanel/ServerAdminsPanel.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useGetOrgUsers } from "@app/hooks/api"; + +export const ServerAdminsPanel = () => { + const [searchUserFilter, setSearchUserFilter] = useState(""); + const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); + const { currentOrg } = useOrganization(); + + const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || ""); + + const adminUsers = orgUsers?.filter((orgUser) => { + const isSuperAdmin = orgUser.user.superAdmin; + const matchesSearch = debounedSearchTerm + ? orgUser.user.email?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || + orgUser.user.firstName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) || + orgUser.user.lastName?.toLowerCase().includes(debounedSearchTerm.toLowerCase()) + : true; + return isSuperAdmin && matchesSearch; + }); + + const isEmpty = !isPending && (!adminUsers || adminUsers.length === 0); + + return ( +
+
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search server admins..." + className="w-full" + /> +
+
+ + + + + + + + + + {isPending && } + {!isPending && + adminUsers?.map(({ user }) => { + const name = + user.firstName || user.lastName + ? `${user.firstName} ${user.lastName}` + : user.username; + return ( + + + + + ); + })} + +
NameEmail
{name}{user.email}
+ {isEmpty && ( +
+ No server administrators found +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx index df51216a6..6654439de 100644 --- a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx @@ -1,6 +1,14 @@ import { useState } from "react"; -import { faMagnifyingGlass, faUsers, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { + faCheckCircle, + faEllipsis, + faFilter, + faMagnifyingGlass, + faUsers, + faUserShield +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -8,7 +16,13 @@ import { Badge, Button, DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, EmptyState, + IconButton, Input, Table, TableContainer, @@ -17,11 +31,7 @@ import { Td, Th, THead, - Tr, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger + Tr } from "@app/components/v2"; import { useSubscription, useUser } from "@app/context"; import { useDebounce, usePopUp } from "@app/hooks"; @@ -48,6 +58,7 @@ const UserPanelTable = ({ ) => void; }) => { const [searchUserFilter, setSearchUserFilter] = useState(""); + const [adminsOnly, setAdminsOnly] = useState(false); const { user } = useUser(); const userId = user?.id || ""; const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); @@ -55,18 +66,55 @@ const UserPanelTable = ({ const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ limit: 20, - searchTerm: debounedSearchTerm + searchTerm: debounedSearchTerm, + adminsOnly }); const isEmpty = !isPending && !data?.pages?.[0].length; + const isTableFiltered = Boolean(adminsOnly); + return ( <> - setSearchUserFilter(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> +
+ setSearchUserFilter(e.target.value)} + leftIcon={} + placeholder="Search users..." + className="flex-1" + /> + + + + + + + + Filter By + { + e.preventDefault(); + setAdminsOnly(!adminsOnly); + }} + icon={adminsOnly && } + iconPos="right" + > +
+ + Server Admins +
+
+
+
+