Merge pull request #3201 from Infisical/feat/addMoreVisibilityToServerAdmins

Add is-admin filter to Server Admin Console and add a component to sh…
This commit is contained in:
Maidul Islam
2025-03-10 14:06:08 -04:00
committed by GitHub
8 changed files with 215 additions and 32 deletions
+6 -1
View File
@@ -118,7 +118,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
querystring: z.object({ querystring: z.object({
searchTerm: z.string().default(""), searchTerm: z.string().default(""),
offset: z.coerce.number().default(0), 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: { response: {
200: z.object({ 200: z.object({
@@ -271,12 +271,13 @@ export const superAdminServiceFactory = ({
return { token, user: userInfo, organization }; return { token, user: userInfo, organization };
}; };
const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => { const getUsers = ({ offset, limit, searchTerm, adminsOnly }: TAdminGetUsersDTO) => {
return userDAL.getUsersByFilter({ return userDAL.getUsersByFilter({
limit, limit,
offset, offset,
searchTerm, searchTerm,
sortBy: "username" sortBy: "username",
adminsOnly
}); });
}; };
@@ -20,6 +20,7 @@ export type TAdminGetUsersDTO = {
offset: number; offset: number;
limit: number; limit: number;
searchTerm: string; searchTerm: string;
adminsOnly: boolean;
}; };
export enum LoginMethod { export enum LoginMethod {
+8 -1
View File
@@ -23,15 +23,18 @@ export const userDALFactory = (db: TDbClient) => {
limit, limit,
offset, offset,
searchTerm, searchTerm,
sortBy sortBy,
adminsOnly
}: { }: {
limit: number; limit: number;
offset: number; offset: number;
searchTerm: string; searchTerm: string;
sortBy?: keyof TUsers; sortBy?: keyof TUsers;
adminsOnly: boolean;
}) => { }) => {
try { try {
let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false); let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false);
if (searchTerm) { if (searchTerm) {
query = query.where((qb) => { query = query.where((qb) => {
void qb void qb
@@ -42,6 +45,10 @@ export const userDALFactory = (db: TDbClient) => {
}); });
} }
if (adminsOnly) {
query = query.where("superAdmin", true);
}
if (sortBy) { if (sortBy) {
query = query.orderBy(sortBy); query = query.orderBy(sortBy);
} }
+1
View File
@@ -50,6 +50,7 @@ export type TUpdateAdminSlackConfigDTO = {
export type AdminGetUsersFilters = { export type AdminGetUsersFilters = {
limit: number; limit: number;
searchTerm: string; searchTerm: string;
adminsOnly: boolean;
}; };
export type AdminSlackConfig = { export type AdminSlackConfig = {
@@ -30,10 +30,13 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
Modal,
ModalContent,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { envConfig } from "@app/config/env"; import { envConfig } from "@app/config/env";
import { useOrganization, useSubscription, useUser } from "@app/context"; import { useOrganization, useSubscription, useUser } from "@app/context";
import { isInfisicalCloud } from "@app/helpers/platform";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { import {
useGetOrganizations, useGetOrganizations,
@@ -50,6 +53,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils"; import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
import { MenuIconButton } from "../MenuIconButton"; import { MenuIconButton } from "../MenuIconButton";
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
const getPlan = (subscription: SubscriptionPlan) => { const getPlan = (subscription: SubscriptionPlan) => {
if (subscription.dynamicSecret) return "Enterprise Plan"; if (subscription.dynamicSecret) return "Enterprise Plan";
@@ -77,6 +81,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [
<FontAwesomeIcon key={4} className="pr-4 text-sm" icon={faEnvelope} />, <FontAwesomeIcon key={4} className="pr-4 text-sm" icon={faEnvelope} />,
"Email Support", "Email Support",
"mailto:[email protected]" "mailto:[email protected]"
],
[
<FontAwesomeIcon key={5} className="pr-4 text-sm" icon={faUsers} />,
"Instance Admins",
"server-admins"
] ]
]; ];
@@ -89,6 +98,7 @@ export const MinimizedOrgSidebar = () => {
const [openSupport, setOpenSupport] = useState(false); const [openSupport, setOpenSupport] = useState(false);
const [openUser, setOpenUser] = useState(false); const [openUser, setOpenUser] = useState(false);
const [openOrg, setOpenOrg] = useState(false); const [openOrg, setOpenOrg] = useState(false);
const [showAdminsModal, setShowAdminsModal] = useState(false);
const { user } = useUser(); const { user } = useUser();
const { mutateAsync } = useGetOrgTrialUrl(); const { mutateAsync } = useGetOrgTrialUrl();
@@ -410,21 +420,39 @@ export const MinimizedOrgSidebar = () => {
side="right" side="right"
className="p-1" className="p-1"
> >
{INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => ( {INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => {
<DropdownMenuItem key={url as string}> if (url === "server-admins" && isInfisicalCloud()) {
<a return null;
target="_blank" }
rel="noopener noreferrer" return (
href={String(url)} <DropdownMenuItem key={url as string}>
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200" {url === "server-admins" ? (
> <button
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md"> type="button"
{icon} onClick={() => setShowAdminsModal(true)}
<div className="text-sm">{text}</div> className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
</div> >
</a> <div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
</DropdownMenuItem> {icon}
))} <div className="text-sm">{text}</div>
</div>
</button>
) : (
<a
target="_blank"
rel="noopener noreferrer"
href={String(url)}
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
>
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
{icon}
<div className="text-sm">{text}</div>
</div>
</a>
)}
</DropdownMenuItem>
);
})}
{envConfig.PLATFORM_VERSION && ( {envConfig.PLATFORM_VERSION && (
<div className="mb-2 mt-2 w-full cursor-default pl-5 text-sm duration-200 hover:text-mineshaft-200"> <div className="mb-2 mt-2 w-full cursor-default pl-5 text-sm duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faInfo} className="mr-4 px-[0.1rem]" /> <FontAwesomeIcon icon={faInfo} className="mr-4 px-[0.1rem]" />
@@ -540,6 +568,13 @@ export const MinimizedOrgSidebar = () => {
</div> </div>
</nav> </nav>
</aside> </aside>
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
<ModalContent title="Server Administrators" subTitle="View all server administrators">
<div className="mb-2">
<ServerAdminsPanel />
</div>
</ModalContent>
</Modal>
<CreateOrgModal <CreateOrgModal
isOpen={popUp?.createOrg?.isOpen} isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)} onClose={() => handlePopUpToggle("createOrg", false)}
@@ -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 (
<div className="flex h-full flex-col">
<div className="mb-4 px-4">
<Input
value={searchUserFilter}
onChange={(e) => setSearchUserFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search server admins..."
className="w-full"
/>
</div>
<div className="flex-1 px-2">
<TableContainer className="flex max-h-[30vh] flex-col overflow-auto">
<Table className="w-full table-fixed">
<THead className="sticky top-0 bg-bunker-800">
<Tr>
<Th className="w-1/2">Name</Th>
<Th className="w-1/2">Email</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="admins" />}
{!isPending &&
adminUsers?.map(({ user }) => {
const name =
user.firstName || user.lastName
? `${user.firstName} ${user.lastName}`
: user.username;
return (
<Tr key={`admin-${user.id}`}>
<Td className="w-1/2 break-words">{name}</Td>
<Td className="w-1/2 break-words">{user.email}</Td>
</Tr>
);
})}
</TBody>
</Table>
{isEmpty && (
<div className="flex h-32 items-center justify-center text-sm text-mineshaft-400">
No server administrators found
</div>
)}
</TableContainer>
</div>
</div>
);
};
@@ -1,6 +1,14 @@
import { useState } from "react"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -8,7 +16,13 @@ import {
Badge, Badge,
Button, Button,
DeleteActionModal, DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton,
Input, Input,
Table, Table,
TableContainer, TableContainer,
@@ -17,11 +31,7 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription, useUser } from "@app/context"; import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
@@ -48,6 +58,7 @@ const UserPanelTable = ({
) => void; ) => void;
}) => { }) => {
const [searchUserFilter, setSearchUserFilter] = useState(""); const [searchUserFilter, setSearchUserFilter] = useState("");
const [adminsOnly, setAdminsOnly] = useState(false);
const { user } = useUser(); const { user } = useUser();
const userId = user?.id || ""; const userId = user?.id || "";
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500); const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
@@ -55,18 +66,55 @@ const UserPanelTable = ({
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
limit: 20, limit: 20,
searchTerm: debounedSearchTerm searchTerm: debounedSearchTerm,
adminsOnly
}); });
const isEmpty = !isPending && !data?.pages?.[0].length; const isEmpty = !isPending && !data?.pages?.[0].length;
const isTableFiltered = Boolean(adminsOnly);
return ( return (
<> <>
<Input <div className="flex gap-2">
value={searchUserFilter} <Input
onChange={(e) => setSearchUserFilter(e.target.value)} value={searchUserFilter}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} onChange={(e) => setSearchUserFilter(e.target.value)}
placeholder="Search users..." leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/> placeholder="Search users..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Users"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setAdminsOnly(!adminsOnly);
}}
icon={adminsOnly && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faUserShield} className="text-yellow-700" />
<span>Server Admins</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-4"> <div className="mt-4">
<TableContainer> <TableContainer>
<Table> <Table>