mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 21:18:18 +00:00
Merge remote-tracking branch 'origin/main' into feat/gateway-v2
This commit is contained in:
+6
-6
@@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { OrganizationsTable } from "./components";
|
||||
import { ServerAdminsTable } from "./components";
|
||||
|
||||
export const OrganizationResourcesPage = () => {
|
||||
export const AccessManagementPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
<title>{t("common.head-title", { title: "Access Control" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Organizations"
|
||||
description="Manage all organizations within your Infisical instance."
|
||||
title="Access Control"
|
||||
description="Manage server admins within your Infisical instance."
|
||||
/>
|
||||
<OrganizationsTable />
|
||||
<ServerAdminsTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } 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, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const getUserLabel = (user: Pick<User, "email" | "firstName" | "lastName" | "username" | "id">) => {
|
||||
const { firstName, lastName, username, email } = user;
|
||||
|
||||
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||
const userEmail = email || username;
|
||||
|
||||
if (!name) return userEmail;
|
||||
|
||||
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||
};
|
||||
|
||||
const AddServerAdminSchema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
username: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddServerAdminSchema>;
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const grantAdmin = useAdminGrantServerAdminAccess();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(AddServerAdminSchema)
|
||||
});
|
||||
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const { data, isFetching } = useAdminGetUsers(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: false
|
||||
},
|
||||
{
|
||||
placeholderData: (prev) => prev
|
||||
}
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]);
|
||||
|
||||
const onSubmit = async ({ user }: FormData) => {
|
||||
try {
|
||||
await grantAdmin.mutateAsync(user.id);
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully granted server admin status"
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to grant server admin status",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="User">
|
||||
<FilterableSelect
|
||||
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||
className="w-full"
|
||||
placeholder="Search users..."
|
||||
options={users}
|
||||
getOptionLabel={(user) => getUserLabel(user)}
|
||||
getOptionValue={(user) => user.id}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onInputChange={(value) => {
|
||||
setSearchUserFilter(value);
|
||||
if (!value) setDebouncedSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="user"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Grant Server Admin"
|
||||
subTitle="Grant server admin status to a user"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,476 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import {
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faShieldHalved,
|
||||
faTrash,
|
||||
faUsers,
|
||||
faUserXmark,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useUser } from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAdminBulkDeleteUsers,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetUsers,
|
||||
useRemoveUserServerAdminAccess
|
||||
} from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal";
|
||||
|
||||
const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user";
|
||||
|
||||
const ServerAdminsPanelTable = ({
|
||||
handlePopUpOpen,
|
||||
users: usersPages,
|
||||
isPending,
|
||||
searchUserFilter,
|
||||
setSearchUserFilter,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
selectedUsers,
|
||||
setSelectedUsers
|
||||
}: {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"]
|
||||
>,
|
||||
data?: {
|
||||
username: string;
|
||||
id: string;
|
||||
message?: string;
|
||||
}
|
||||
) => void;
|
||||
isPending: boolean;
|
||||
users: InfiniteData<User[], unknown> | undefined;
|
||||
searchUserFilter: string;
|
||||
setSearchUserFilter: (filter: string) => void;
|
||||
selectedUsers: User[];
|
||||
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
}) => {
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const users = usersPages?.pages.flat();
|
||||
|
||||
const isEmpty = !isPending && !users?.length;
|
||||
|
||||
const selectedUserIds = selectedUsers.map((user) => user.id);
|
||||
|
||||
const isPageSelected = users?.length
|
||||
? users.every((user) => selectedUserIds.includes(user.id))
|
||||
: false;
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const isPageIndeterminate = isPageSelected
|
||||
? false
|
||||
: users?.length
|
||||
? users?.some((user) => selectedUserIds.includes(user.id))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search admins..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addServerAdmin")}
|
||||
>
|
||||
Add Admin
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5">
|
||||
<Checkbox
|
||||
id="member-page-select"
|
||||
isChecked={isPageSelected || isPageIndeterminate}
|
||||
isIndeterminate={isPageIndeterminate}
|
||||
onCheckedChange={() => {
|
||||
if (isPageSelected) {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.filter((u) => !users?.find((user) => user.id === u.id))
|
||||
);
|
||||
} else {
|
||||
setSelectedUsers((prev) => [
|
||||
...prev,
|
||||
...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? [])
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Th>
|
||||
<Th className="w-5/12">Name</Th>
|
||||
<Th className="w-1/2">Username</Th>
|
||||
<Th className="w-2/12" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="users" />}
|
||||
{!isPending &&
|
||||
users?.map((user) => {
|
||||
const { username, email, firstName, lastName, id } = user;
|
||||
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||
|
||||
const isSelected = selectedUserIds.includes(id);
|
||||
return (
|
||||
<Tr key={`user-${id}`} className="w-full">
|
||||
<Td>
|
||||
<Checkbox
|
||||
id={`select-user-${id}`}
|
||||
isChecked={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUsers((prev) =>
|
||||
isSelected ? prev.filter((u) => u.id !== id) : [...prev, user]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">
|
||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||
</p>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">{username || email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeUser", { username, id });
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||
>
|
||||
Remove User
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
icon={
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faShieldHalved} />
|
||||
<FontAwesomeIcon
|
||||
className="absolute -bottom-[0.01rem] -right-1"
|
||||
size="2xs"
|
||||
icon={faXmark}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!subscription?.instanceUserManagement) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
username,
|
||||
id,
|
||||
message: removeServerAdminUpgradePlanMessage
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("removeServerAdmin", { username, id });
|
||||
}}
|
||||
>
|
||||
Remove Server Admin
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of List"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServerAdminsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"removeUser",
|
||||
"upgradePlan",
|
||||
"addServerAdmin",
|
||||
"removeServerAdmin",
|
||||
"removeUsers"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
user: { id: userId }
|
||||
} = useUser();
|
||||
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers();
|
||||
const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage
|
||||
} = useAdminGetUsers({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: true
|
||||
});
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
const { id } = popUp?.removeUser?.data as { id: string; username: string };
|
||||
|
||||
try {
|
||||
await deleteUser(id);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Error deleting user"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeUser");
|
||||
};
|
||||
|
||||
const handleRemoveServerAdminAccess = async () => {
|
||||
const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string };
|
||||
|
||||
try {
|
||||
await removeAdminAccess(id);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed server admin access from user"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Error removing server admin access from user"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeServerAdmin");
|
||||
};
|
||||
|
||||
const handleRemoveUsers = async () => {
|
||||
try {
|
||||
await deleteUsers(selectedUsers.map((user) => user.id));
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed users",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
handlePopUpClose("removeUsers");
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to remove users",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={twMerge(
|
||||
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||
selectedUsers.length > 0 && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
|
||||
<div className="mr-2 text-sm">{selectedUsers.length} Selected</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
|
||||
onClick={() => setSelectedUsers([])}
|
||||
>
|
||||
Unselect All
|
||||
</button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
if (!selectedUsers?.length) return;
|
||||
|
||||
handlePopUpOpen("removeUsers");
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<ServerAdminsPanelTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
setSelectedUsers={setSelectedUsers}
|
||||
searchUserFilter={searchUserFilter}
|
||||
setSearchUserFilter={setSearchUserFilter}
|
||||
isPending={isPending}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUser.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Are you sure you want to delete User with username ${
|
||||
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
|
||||
onDeleteApproved={handleRemoveUser}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeServerAdmin.isOpen}
|
||||
title={`Are you sure you want to remove Server Admin permissions from ${
|
||||
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
|
||||
}?`}
|
||||
subTitle=""
|
||||
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={handleRemoveServerAdminAccess}
|
||||
buttonText="Remove Access"
|
||||
/>
|
||||
<AddServerAdminModal
|
||||
isOpen={popUp.addServerAdmin.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addServerAdmin", isOpen)}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUsers.isOpen}
|
||||
title="Are you sure you want to delete the following users?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleRemoveUsers()}
|
||||
buttonText="Remove"
|
||||
>
|
||||
<div className="mt-4 text-sm text-mineshaft-400">
|
||||
The following users will be deleted:
|
||||
</div>
|
||||
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
|
||||
<ul className="list-disc">
|
||||
{selectedUsers?.map((user) => {
|
||||
const email = user.email ?? user.username;
|
||||
return (
|
||||
<li key={user.id}>
|
||||
<div className="flex items-center">
|
||||
<p>
|
||||
{user.firstName || user.lastName ? (
|
||||
<>
|
||||
{`${`${user.firstName} ${user.lastName}`.trim()} `}(
|
||||
<span className="break-all">{email}</span>)
|
||||
</>
|
||||
) : (
|
||||
<span className="break-all">{email}</span>
|
||||
)}{" "}
|
||||
</p>
|
||||
{userId === user.id && (
|
||||
<Tooltip content="Are you sure you want to remove yourself from this instance?">
|
||||
<div className="inline-block">
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>Deleting Yourself</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</DeleteActionModal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ServerAdminsTable";
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage";
|
||||
import { AccessManagementPage } from "./AccessManagementPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities"
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/access-management"
|
||||
)({
|
||||
component: UserIdentitiesResourcesPage,
|
||||
component: AccessManagementPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "User Identities",
|
||||
label: "Access Control",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/user-identities"
|
||||
to: "/admin/access-management"
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { useGetServerConfig } from "@app/hooks/api/admin";
|
||||
|
||||
import { GeneralPageForm } from "./components";
|
||||
import { GeneralPageForm, UsageReportSection } from "./components";
|
||||
|
||||
export const GeneralPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: serverConfig } = useGetServerConfig();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
@@ -19,7 +21,10 @@ export const GeneralPage = () => {
|
||||
title="General"
|
||||
description="Manage general settings for your Infisical instance."
|
||||
/>
|
||||
<GeneralPageForm />
|
||||
<div className="space-y-6">
|
||||
<GeneralPageForm />
|
||||
{serverConfig?.isOfflineUsageReportsEnabled && <UsageReportSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export const GeneralPageForm = () => {
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-8 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
className="space-y-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className="flex flex-col justify-start">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Card, CardTitle } from "@app/components/v2";
|
||||
import { downloadFile } from "@app/helpers/download";
|
||||
import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation";
|
||||
|
||||
export const UsageReportSection = () => {
|
||||
const generateUsageReport = useGenerateUsageReport();
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
try {
|
||||
const response = await generateUsageReport.mutateAsync();
|
||||
const { csvContent, filename } = response;
|
||||
|
||||
downloadFile(csvContent, filename, "text/csv");
|
||||
|
||||
createNotification({
|
||||
text: `Usage report downloaded: "${filename}"`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to generate usage report:", error);
|
||||
createNotification({
|
||||
text: "Failed to generate usage report. Please try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<CardTitle className="mb-4 flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faFileAlt} />
|
||||
Offline Usage Reports
|
||||
</CardTitle>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-400">
|
||||
Generate secure usage reports for offline license compliance and billing verification.
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateReport}
|
||||
className="w-fit"
|
||||
isLoading={generateUsageReport.isPending}
|
||||
leftIcon={<FontAwesomeIcon icon={generateUsageReport.isPending ? faSpinner : faDownload} />}
|
||||
>
|
||||
{generateUsageReport.isPending ? "Generating..." : "Generate Report"}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { GeneralPageForm } from "./GeneralPageForm";
|
||||
export { UsageReportSection } from "./UsageReportSection";
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { MachineIdentitiesTable } from "./components";
|
||||
|
||||
export const MachineIdentitiesResourcesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Machine Identities"
|
||||
description="Manage all machine identities within your Infisical instance."
|
||||
/>
|
||||
<MachineIdentitiesTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { MachineIdentitiesTable } from "./MachineIdentitiesTable";
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities"
|
||||
)({
|
||||
component: MachineIdentitiesResourcesPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Admin",
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Machine Identities",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/machine-identities"
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faBuilding,
|
||||
faCircleQuestion,
|
||||
faEllipsis,
|
||||
faMagnifyingGlass
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAdminDeleteOrganization,
|
||||
useAdminDeleteOrganizationMembership,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetOrganizations
|
||||
} from "@app/hooks/api";
|
||||
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const ViewMembersModalContent = ({
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
popUp: UsePopUpState<["viewMembers"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||
data?: {
|
||||
username?: string;
|
||||
membershipId?: string;
|
||||
userId?: string;
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
}) => {
|
||||
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{organization?.members?.map((member) => (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-mineshaft-100">
|
||||
<div>
|
||||
{member.user.firstName ? (
|
||||
<div>
|
||||
{member.user.firstName} {member.user.lastName}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-mineshaft-400">Not set</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-80">
|
||||
<div>{member.user.username || member.user.email}</div>
|
||||
<Badge variant="primary">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="capitalize">{member.role.replace("-", " ")}</span>
|
||||
{Boolean(member.roleId) && (
|
||||
<Tooltip content="This member has a custom role assigned.">
|
||||
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<FontAwesomeIcon
|
||||
icon={faEllipsis}
|
||||
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteOrganizationMembership", {
|
||||
membershipId: member.membershipId,
|
||||
orgId: organization.id,
|
||||
username: member.user.username,
|
||||
orgName: organization.name
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove From Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewMembersModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||
) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
title="Organization Members"
|
||||
subTitle="View the members of the organization."
|
||||
>
|
||||
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationsPanelTable = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: {
|
||||
popUp: UsePopUpState<
|
||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||
>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||
>,
|
||||
data?: {
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
message?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||
isOpen?: boolean
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useAdminGetOrganizations({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchOrganizationsFilter}
|
||||
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organizations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5/12">Name</Th>
|
||||
<Th className="w-5/12">Members</Th>
|
||||
<Th className="w-5/12">Projects</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((orgs) =>
|
||||
orgs.map((org) => {
|
||||
return (
|
||||
<Tr key={`org-${org.id}`} className="w-full">
|
||||
<Td className="w-5/12">
|
||||
{org.name ? (
|
||||
org.name
|
||||
) : (
|
||||
<span className="text-mineshaft-400">Not set</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="w-5/12">
|
||||
{org.members.length} {org.members.length === 1 ? "member" : "members"}
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="ml-2"
|
||||
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
||||
>
|
||||
View Members
|
||||
</Button>
|
||||
</Td>
|
||||
<Td className="w-5/12">
|
||||
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteOrganization", {
|
||||
orgId: org.id,
|
||||
orgName: org.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ViewMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
isOpen={popUp.viewMembers.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteOrganization",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"viewMembers"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
||||
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
||||
|
||||
await deleteOrganization(orgId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteOrganization");
|
||||
};
|
||||
|
||||
const handleDeleteOrganizationMembership = async () => {
|
||||
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
if (!orgId || !membershipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed user from organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteOrganizationMembership");
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteUser");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<OrganizationsPanelTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganization.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete organization ${
|
||||
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganization}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to remove ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||
} from organization ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteUser.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete user ${
|
||||
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||
onDeleteApproved={handleDeleteUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { OrganizationsTable } from "./OrganizationsTable";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
|
||||
import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components";
|
||||
|
||||
export const ResourceOverviewPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Resource Overview" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Resource Overview"
|
||||
description="Manage resources within your Infisical instance."
|
||||
/>
|
||||
<Tabs defaultValue="tab-organizations">
|
||||
<TabList>
|
||||
<Tab value="tab-organizations">Organizations</Tab>
|
||||
<Tab value="tab-users">Users</Tab>
|
||||
<Tab value="tab-identities">Identities</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="tab-organizations">
|
||||
<OrganizationsTable />
|
||||
</TabPanel>
|
||||
<TabPanel value="tab-users">
|
||||
<UserIdentitiesTable />
|
||||
</TabPanel>
|
||||
<TabPanel value="tab-identities">
|
||||
<MachineIdentitiesTable />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
import { GenericResourceNameSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type Invitee = Pick<User, "email" | "firstName" | "lastName" | "username" | "id">;
|
||||
type NewOption = { label: string; value: string };
|
||||
|
||||
const getUserLabel = (user: Invitee | NewOption) => {
|
||||
if (Object.prototype.hasOwnProperty.call(user, "value")) {
|
||||
return (user as NewOption).label;
|
||||
}
|
||||
|
||||
const { firstName, lastName, username, email } = user as Invitee;
|
||||
|
||||
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||
const userEmail = email || username;
|
||||
|
||||
if (!name) return userEmail;
|
||||
|
||||
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||
};
|
||||
|
||||
const AddOrgSchema = z.object({
|
||||
name: GenericResourceNameSchema.nonempty("Organization name required"),
|
||||
invitees: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
username: z.string().nullish()
|
||||
})
|
||||
.array()
|
||||
.min(1, "At least one admin is required")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddOrgSchema>;
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const createOrg = useServerAdminCreateOrganization();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
invitees: []
|
||||
},
|
||||
resolver: zodResolver(AddOrgSchema)
|
||||
});
|
||||
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const { data, isFetching } = useAdminGetUsers(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: false
|
||||
},
|
||||
{
|
||||
placeholderData: (prev) => prev
|
||||
}
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const onSubmit = async ({ name, invitees }: FormData) => {
|
||||
try {
|
||||
await createOrg.mutateAsync({
|
||||
name,
|
||||
inviteAdminEmails: invitees
|
||||
.filter((user) => Boolean(user.email))
|
||||
.map((user) => user.email) as string[]
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created organization"
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to create organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { append } = useFieldArray<FormData>({ control, name: "invitees" });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Name">
|
||||
<Input autoFocus value={value} onChange={onChange} placeholder="My Organization" />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
<Controller
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Assign Organization Admins"
|
||||
>
|
||||
<CreatableSelect
|
||||
/* eslint-disable-next-line react/no-unstable-nested-components */
|
||||
noOptionsMessage={() => (
|
||||
<p>Invite new users to this organization by typing out their email address.</p>
|
||||
)}
|
||||
onCreateOption={(inputValue) =>
|
||||
append({ id: `${inputValue}_${Math.random()}`, email: inputValue })
|
||||
}
|
||||
formatCreateLabel={(inputValue) => `Invite "${inputValue}"`}
|
||||
isValidNewOption={(input) =>
|
||||
Boolean(input) &&
|
||||
z.string().email().safeParse(input).success &&
|
||||
!users
|
||||
?.flatMap((user) => {
|
||||
const emails: string[] = [];
|
||||
|
||||
if (user.email) {
|
||||
emails.push(user.email);
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
emails.push(user.username);
|
||||
}
|
||||
|
||||
return emails;
|
||||
})
|
||||
.includes(input)
|
||||
}
|
||||
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||
className="w-full"
|
||||
placeholder="Search users or invite new ones..."
|
||||
isMulti
|
||||
name="members"
|
||||
options={users}
|
||||
getOptionLabel={(user) => getUserLabel(user)}
|
||||
getOptionValue={(user) => user.id}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onInputChange={(value) => {
|
||||
setSearchUserFilter(value);
|
||||
if (!value) setDebouncedSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="invitees"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Add Organization
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent bodyClassName="overflow-visible" title="Add Organization">
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+8
-2
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import {
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faServer,
|
||||
faShieldHalved,
|
||||
faWrench,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -136,7 +136,7 @@ const IdentityPanelTable = ({
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
@@ -183,6 +183,12 @@ export const MachineIdentitiesTable = () => {
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Machine Identities</p>
|
||||
<p className="text-sm text-bunker-300">Manage machine identities across your instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeServerAdmin.isOpen}
|
||||
@@ -0,0 +1,752 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faBuilding,
|
||||
faCircleQuestion,
|
||||
faEllipsisV,
|
||||
faEnvelope,
|
||||
faEye,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faTrash,
|
||||
faUserCheck,
|
||||
faUserMinus,
|
||||
faUserPlus,
|
||||
faUsers,
|
||||
faUserXmark,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useAdminDeleteOrganization,
|
||||
useAdminDeleteOrganizationMembership,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetOrganizations,
|
||||
useServerAdminAccessOrg,
|
||||
useServerAdminResendOrgInvite
|
||||
} from "@app/hooks/api";
|
||||
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { AddOrganizationModal } from "@app/pages/admin/ResourceOverviewPage/components/AddOrganizationModal";
|
||||
|
||||
enum MembersOrderBy {
|
||||
Name = "firstName",
|
||||
Email = "email"
|
||||
}
|
||||
|
||||
const ORG_MEMBERS_TABLE_LIMIT = 15;
|
||||
|
||||
const ViewMembersModalContent = ({
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
popUp: UsePopUpState<["viewMembers"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||
data?: {
|
||||
username?: string;
|
||||
membershipId?: string;
|
||||
userId?: string;
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
}) => {
|
||||
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
|
||||
|
||||
const members = organization?.members ?? [];
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
|
||||
initPerPage: ORG_MEMBERS_TABLE_LIMIT
|
||||
});
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() =>
|
||||
members
|
||||
?.filter(
|
||||
({ user: u }) =>
|
||||
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.email?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
let valueOne: string | null;
|
||||
let valueTwo: string | null;
|
||||
|
||||
switch (orderBy) {
|
||||
case MembersOrderBy.Email:
|
||||
valueOne = memberOne.user.email || memberOne.user.username;
|
||||
valueTwo = memberTwo.user.email || memberTwo.user.username;
|
||||
break;
|
||||
case MembersOrderBy.Name:
|
||||
default:
|
||||
valueOne = memberOne.user.firstName ?? memberOne.user.lastName;
|
||||
valueTwo = memberTwo.user.firstName ?? memberTwo.user.lastName;
|
||||
}
|
||||
|
||||
if (!valueOne) return 1;
|
||||
if (!valueTwo) return -1;
|
||||
|
||||
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
|
||||
}),
|
||||
[members, search, orderBy, orderDirection]
|
||||
);
|
||||
|
||||
const handleSort = (column: MembersOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredMembers.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const resendOrgInvite = useServerAdminResendOrgInvite();
|
||||
|
||||
const onResendInvite = async (membershipId: string) => {
|
||||
setResendInviteId(membershipId);
|
||||
try {
|
||||
await resendOrgInvite.mutateAsync({
|
||||
membershipId,
|
||||
organizationId: organization.id
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully resent org invitation",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to resend org invitation",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setResendInviteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<TableContainer
|
||||
className={twMerge(
|
||||
"mt-4 flex flex-1 flex-col border border-mineshaft-500 bg-mineshaft-700",
|
||||
Boolean(filteredMembers.length) && "rounded-b-none"
|
||||
)}
|
||||
>
|
||||
<Table className="overflow-y-auto bg-mineshaft-700">
|
||||
<THead className="sticky top-0 z-50">
|
||||
<Tr>
|
||||
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(MembersOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||
Email
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(MembersOrderBy.Email)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/4 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
|
||||
Role
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5 border-none bg-mineshaft-700 p-0">
|
||||
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5" />
|
||||
</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredMembers.slice(offset, perPage * page).map((member) => {
|
||||
const { username, email, firstName, lastName, id } = member.user;
|
||||
const { role, status } = member;
|
||||
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||
|
||||
return (
|
||||
<Tr key={`user-${id}`} className="w-full">
|
||||
<Td className="max-w-0">
|
||||
<div className="flex items-center">
|
||||
<p className="truncate">
|
||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||
</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="max-w-0">
|
||||
<div className="flex items-center">
|
||||
<p className="truncate">{username || email}</p>
|
||||
{role === OrgMembershipRole.Admin &&
|
||||
status !== OrgMembershipStatus.Accepted && (
|
||||
<Button
|
||||
isDisabled={resendOrgInvite.isPending}
|
||||
className="ml-2 h-7 border-mineshaft-600 bg-mineshaft-800/50 font-normal"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isLoading={
|
||||
resendOrgInvite.isPending && resendInviteId === member.membershipId
|
||||
}
|
||||
leftIcon={<FontAwesomeIcon icon={faEnvelope} />}
|
||||
onClick={(e) => {
|
||||
onResendInvite(member.membershipId);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Resend Invite
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="max-w-0">
|
||||
<Badge className="flex w-fit max-w-full items-center gap-x-1 whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
|
||||
<p className="truncate capitalize">{member.role.replace("-", " ")}</p>
|
||||
{Boolean(member.roleId) && (
|
||||
<Tooltip content="This member has a custom role assigned.">
|
||||
<FontAwesomeIcon icon={faCircleQuestion} className="w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserMinus} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteOrganizationMembership", {
|
||||
membershipId: member.membershipId,
|
||||
orgId: organization.id,
|
||||
username: member.user.username,
|
||||
orgName: organization.name
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove From Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteUser", { userId: member.user.id })
|
||||
}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!filteredMembers.length && (
|
||||
<EmptyState
|
||||
className="my-auto bg-mineshaft-700"
|
||||
title={
|
||||
members.length
|
||||
? "No organization members match search..."
|
||||
: "No organization members found"
|
||||
}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
{Boolean(filteredMembers.length) && (
|
||||
<Pagination
|
||||
className="rounded-b-md border border-t-0 bg-mineshaft-700"
|
||||
count={filteredMembers.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
perPageList={[ORG_MEMBERS_TABLE_LIMIT]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewMembersModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||
) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
title="Organization Members"
|
||||
subTitle="View the members of the organization."
|
||||
className="h-full max-w-4xl"
|
||||
bodyClassName="flex flex-col h-full"
|
||||
>
|
||||
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationsPanelTable = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: {
|
||||
popUp: UsePopUpState<
|
||||
[
|
||||
"deleteOrganization",
|
||||
"viewMembers",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"createOrganization"
|
||||
]
|
||||
>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
[
|
||||
"deleteOrganization",
|
||||
"viewMembers",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"createOrganization"
|
||||
]
|
||||
>,
|
||||
data?: {
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
message?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||
isOpen?: boolean
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useAdminGetOrganizations({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
const { mutateAsync: accessOrganization } = useServerAdminAccessOrg();
|
||||
|
||||
const handleAccessOrg = async (orgId: string) => {
|
||||
try {
|
||||
await accessOrganization(orgId);
|
||||
|
||||
navigate({
|
||||
to: "/login/select-organization",
|
||||
search: {
|
||||
org_id: orgId
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully joined organization",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to join organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={searchOrganizationsFilter}
|
||||
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organizations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/2">Name</Th>
|
||||
<Th className="w-1/3">Members</Th>
|
||||
<Th className="w-1/3">Projects</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((orgs) =>
|
||||
orgs.map((org) => {
|
||||
const isMember = org.members.find((member) => member.user.id === user.id);
|
||||
|
||||
return (
|
||||
<Tr key={`org-${org.id}`} className="w-full">
|
||||
<Td className="w-1/2 max-w-0">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{org.name ? (
|
||||
<p className="truncate">{org.name}</p>
|
||||
) : (
|
||||
<span className="text-mineshaft-400">Not Set</span>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="w-1/3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
||||
className="flex items-center hover:underline"
|
||||
>
|
||||
<Tooltip className="text-center" content="View Members">
|
||||
<FontAwesomeIcon
|
||||
icon={faEye}
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
{org.members.length} {org.members.length === 1 ? "Member" : "Members"}
|
||||
{!org.members.some(
|
||||
(member) =>
|
||||
member.role === OrgMembershipRole.Admin &&
|
||||
member.status === OrgMembershipStatus.Accepted
|
||||
) && (
|
||||
<Tooltip content="No admins have accepted their invitations.">
|
||||
<div className="ml-1.5">
|
||||
<FontAwesomeIcon className="text-yellow" icon={faWarning} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</button>
|
||||
</Td>
|
||||
<Td className="w-1/3">
|
||||
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end gap-x-1">
|
||||
{isMember && (
|
||||
<Tooltip
|
||||
className="text-center"
|
||||
content="You are a member of this organization"
|
||||
>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
className="text-mineshaft-400"
|
||||
icon={faUserCheck}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
{!isMember && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAccessOrg(org.id);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faUserPlus} />}
|
||||
>
|
||||
Join Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteOrganization", {
|
||||
orgId: org.id,
|
||||
orgName: org.name
|
||||
});
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ViewMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
isOpen={popUp.viewMembers.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteOrganization",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"viewMembers",
|
||||
"createOrganization"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
||||
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
||||
|
||||
await deleteOrganization(orgId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteOrganization");
|
||||
};
|
||||
|
||||
const handleDeleteOrganizationMembership = async () => {
|
||||
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
if (!orgId || !membershipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed user from organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteOrganizationMembership");
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteUser");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Organizations</p>
|
||||
<p className="text-sm text-bunker-300">
|
||||
Manage, join and view organizations across your instance.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => handlePopUpOpen("createOrganization")}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Organization
|
||||
</Button>
|
||||
</div>
|
||||
<OrganizationsPanelTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganization.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete organization ${
|
||||
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganization}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to remove ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||
} from organization ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteUser.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete user ${
|
||||
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||
onDeleteApproved={handleDeleteUser}
|
||||
/>
|
||||
<AddOrganizationModal
|
||||
isOpen={popUp.createOrganization.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("createOrganization", isOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+11
-5
@@ -218,7 +218,7 @@ const UserPanelTable = ({
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">{email}</p>
|
||||
<p className="truncate">{username || email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
@@ -463,6 +463,12 @@ export const UserIdentitiesTable = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-semibold text-mineshaft-100">User Identities</p>
|
||||
<p className="text-sm text-bunker-300">Manage user identities across your instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserPanelTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
users={users}
|
||||
@@ -515,14 +521,14 @@ export const UserIdentitiesTable = () => {
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUsers.isOpen}
|
||||
title="Are you sure you want to remove the following users?"
|
||||
title="Are you sure you want to delete the following users?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleRemoveUsers()}
|
||||
buttonText="Remove"
|
||||
buttonText="Delete"
|
||||
>
|
||||
<div className="mt-4 text-sm text-mineshaft-400">
|
||||
The following members will be removed:
|
||||
The following users will be deleted:
|
||||
</div>
|
||||
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
|
||||
<ul className="list-disc">
|
||||
@@ -549,7 +555,7 @@ export const UserIdentitiesTable = () => {
|
||||
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>Removing Yourself</span>
|
||||
<span>Deleting Yourself</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./MachineIdentitiesTable";
|
||||
export * from "./OrganizationsTable";
|
||||
export * from "./UserIdentitiesTable";
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { OrganizationResourcesPage } from "./OrganizationResourcesPage";
|
||||
import { ResourceOverviewPage } from "./ResourceOverviewPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations"
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview"
|
||||
)({
|
||||
component: OrganizationResourcesPage,
|
||||
component: ResourceOverviewPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Organizations",
|
||||
label: "Resource Overview",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/organizations"
|
||||
to: "/admin/resources/overview"
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { UserIdentitiesTable } from "./components";
|
||||
|
||||
export const UserIdentitiesResourcesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="User Identities"
|
||||
description="Manage all user identities within your Infisical instance."
|
||||
/>
|
||||
<UserIdentitiesTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { UserIdentitiesTable } from "./UserIdentitiesTable";
|
||||
@@ -88,7 +88,15 @@ export const SelectOrganizationSection = () => {
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
let url = "";
|
||||
if (organization.orgAuthMethod === AuthMethod.OIDC) {
|
||||
if (organization.googleSsoAuthEnforced) {
|
||||
if (authToken.authMethod !== AuthMethod.GOOGLE) {
|
||||
url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`;
|
||||
|
||||
if (callbackPort) {
|
||||
url += `&callback_port=${callbackPort}`;
|
||||
}
|
||||
}
|
||||
} else if (organization.orgAuthMethod === AuthMethod.OIDC) {
|
||||
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
|
||||
callbackPort ? `&callbackPort=${callbackPort}` : ""
|
||||
}`;
|
||||
@@ -98,15 +106,6 @@ export const SelectOrganizationSection = () => {
|
||||
if (callbackPort) {
|
||||
url += `?callback_port=${callbackPort}`;
|
||||
}
|
||||
} else if (
|
||||
organization.googleSsoAuthEnforced &&
|
||||
authToken.authMethod !== AuthMethod.GOOGLE
|
||||
) {
|
||||
url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`;
|
||||
|
||||
if (callbackPort) {
|
||||
url += `&callback_port=${callbackPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
// we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO
|
||||
|
||||
@@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery";
|
||||
|
||||
const QueryParamsSchema = z.object({
|
||||
callback_port: z.coerce.number().optional().catch(undefined),
|
||||
force: z.boolean().optional()
|
||||
force: z.boolean().optional(),
|
||||
org_id: z.string().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export const AuthConsentWrapper = () => {
|
||||
@@ -102,6 +103,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({
|
||||
return;
|
||||
}
|
||||
|
||||
if (search.org_id) {
|
||||
if (location.pathname.endsWith("select-organization")) return;
|
||||
|
||||
throw redirect({ to: "/login/select-organization", search: { org_id: search.org_id } });
|
||||
}
|
||||
|
||||
if (!data.organizationId) {
|
||||
if (
|
||||
location.pathname.endsWith("select-organization") ||
|
||||
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, FormLabel, IconButton, Input, Spinner } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useCreateAuditLogStream,
|
||||
useGetAuditLogStreamDetails,
|
||||
useUpdateAuditLogStream
|
||||
} from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const formSchema = z.object({
|
||||
url: z.string().url().min(1),
|
||||
headers: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
value: z.string()
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export const AuditLogStreamForm = ({ id = "", onClose }: Props) => {
|
||||
const isEdit = Boolean(id);
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const auditLogStream = useGetAuditLogStreamDetails(id);
|
||||
const createAuditLogStream = useCreateAuditLogStream();
|
||||
const updateAuditLogStream = useUpdateAuditLogStream();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TForm>({
|
||||
values: auditLogStream?.data,
|
||||
defaultValues: {
|
||||
headers: [{ key: "", value: "" }]
|
||||
}
|
||||
});
|
||||
|
||||
const headerFields = useFieldArray({
|
||||
control,
|
||||
name: "headers"
|
||||
});
|
||||
|
||||
const handleAuditLogStreamEdit = async ({ headers, url }: TForm) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateAuditLogStream.mutateAsync({
|
||||
id,
|
||||
orgId,
|
||||
headers,
|
||||
url
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated stream"
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update stream"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async ({ headers = [], url }: TForm) => {
|
||||
if (isSubmitting) return;
|
||||
const sanitizedHeaders = headers.filter(({ key, value }) => Boolean(key) && Boolean(value));
|
||||
const streamHeaders = sanitizedHeaders.length ? sanitizedHeaders : undefined;
|
||||
if (isEdit) {
|
||||
await handleAuditLogStreamEdit({ headers: streamHeaders, url });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createAuditLogStream.mutateAsync({
|
||||
orgId,
|
||||
headers: streamHeaders,
|
||||
url
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created stream"
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: (err as Error)?.message ?? "Failed to create stream"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && auditLogStream.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Endpoint URL"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormLabel label="Headers" isOptional />
|
||||
{headerFields.fields.map(({ id: headerFieldId }, i) => (
|
||||
<div key={headerFieldId} className="flex space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`headers.${i}.key`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-1/3"
|
||||
>
|
||||
<Input {...field} placeholder="Authorization" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`headers.${i}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Bearer <token>"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
ariaLabel="delete key"
|
||||
className="h-9"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const header = getValues("headers");
|
||||
if (header && header?.length > 1) {
|
||||
headerFields.remove(i);
|
||||
} else {
|
||||
setValue("headers", [{ key: "", value: "" }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() => headerFields.append({ value: "", key: "" })}
|
||||
>
|
||||
Add Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isLoading={isSubmitting}>
|
||||
{isEdit ? "Save" : "Create"}
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
|
||||
import { useCreateAuditLogStream, useUpdateAuditLogStream } from "@app/hooks/api";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
import { TAuditLogStream } from "@app/hooks/api/types";
|
||||
import { DiscriminativePick } from "@app/types";
|
||||
|
||||
import { AuditLogStreamHeader } from "../components/AuditLogStreamHeader";
|
||||
import { CustomProviderAuditLogStreamForm } from "./CustomProviderAuditLogStreamForm";
|
||||
import { DatadogProviderAuditLogStreamForm } from "./DatadogProviderAuditLogStreamForm";
|
||||
import { SplunkProviderAuditLogStreamForm } from "./SplunkProviderAuditLogStreamForm";
|
||||
|
||||
type FormProps = {
|
||||
onComplete: (auditLogStream: TAuditLogStream) => void;
|
||||
};
|
||||
|
||||
type CreateFormProps = FormProps & { provider: LogProvider };
|
||||
type UpdateFormProps = FormProps & {
|
||||
auditLogStream: TAuditLogStream;
|
||||
};
|
||||
|
||||
const CreateForm = ({ provider, onComplete }: CreateFormProps) => {
|
||||
const createAuditLogStream = useCreateAuditLogStream();
|
||||
const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TAuditLogStream, "provider" | "credentials">
|
||||
) => {
|
||||
try {
|
||||
const logStream = await createAuditLogStream.mutateAsync(formData);
|
||||
createNotification({
|
||||
text: `Successfully created ${providerName} Log Stream`,
|
||||
type: "success"
|
||||
});
|
||||
onComplete(logStream);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
title: `Failed to create ${providerName} Log Stream`,
|
||||
text: err.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (provider) {
|
||||
case LogProvider.Custom:
|
||||
return <CustomProviderAuditLogStreamForm onSubmit={onSubmit} />;
|
||||
case LogProvider.Datadog:
|
||||
return <DatadogProviderAuditLogStreamForm onSubmit={onSubmit} />;
|
||||
case LogProvider.Splunk:
|
||||
return <SplunkProviderAuditLogStreamForm onSubmit={onSubmit} />;
|
||||
default:
|
||||
throw new Error(`Unhandled Provider: ${provider}`);
|
||||
}
|
||||
};
|
||||
|
||||
const UpdateForm = ({ auditLogStream, onComplete }: UpdateFormProps) => {
|
||||
const updateAuditLogStream = useUpdateAuditLogStream();
|
||||
const { name: providerName } = AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider];
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TAuditLogStream, "provider" | "credentials">
|
||||
) => {
|
||||
try {
|
||||
const connection = await updateAuditLogStream.mutateAsync({
|
||||
auditLogStreamId: auditLogStream.id,
|
||||
...formData
|
||||
});
|
||||
createNotification({
|
||||
text: `Successfully updated ${providerName} Log Stream`,
|
||||
type: "success"
|
||||
});
|
||||
onComplete(connection);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
title: `Failed to update ${providerName} Log Stream`,
|
||||
text: err.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (auditLogStream.provider) {
|
||||
case LogProvider.Custom:
|
||||
return (
|
||||
<CustomProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
|
||||
);
|
||||
case LogProvider.Datadog:
|
||||
return (
|
||||
<DatadogProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
|
||||
);
|
||||
case LogProvider.Splunk:
|
||||
return (
|
||||
<SplunkProviderAuditLogStreamForm onSubmit={onSubmit} auditLogStream={auditLogStream} />
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unhandled Provider: ${(auditLogStream as TAuditLogStream).provider}`);
|
||||
}
|
||||
};
|
||||
|
||||
type Props = { onBack?: () => void } & Pick<FormProps, "onComplete"> &
|
||||
(
|
||||
| { provider: LogProvider; auditLogStream?: undefined }
|
||||
| { provider?: undefined; auditLogStream: TAuditLogStream }
|
||||
);
|
||||
export const AuditLogStreamForm = ({ onBack, ...props }: Props) => {
|
||||
const { provider, auditLogStream } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AuditLogStreamHeader
|
||||
logStreamExists={Boolean(auditLogStream)}
|
||||
provider={auditLogStream ? auditLogStream.provider : provider}
|
||||
onBack={onBack}
|
||||
/>
|
||||
{auditLogStream ? (
|
||||
<UpdateForm {...props} auditLogStream={auditLogStream} />
|
||||
) : (
|
||||
<CreateForm {...props} provider={provider} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, FormLabel, IconButton, Input, ModalClose } from "@app/components/v2";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
import { TCustomProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/custom-provider";
|
||||
|
||||
type Props = {
|
||||
auditLogStream?: TCustomProviderLogStream;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.literal(LogProvider.Custom),
|
||||
credentials: z.object({
|
||||
url: z.string().url().trim().min(1).max(255),
|
||||
headers: z
|
||||
.object({
|
||||
key: z.string().min(1),
|
||||
value: z.string().min(1)
|
||||
})
|
||||
.array()
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(auditLogStream);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: auditLogStream ?? {
|
||||
provider: LogProvider.Custom
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty },
|
||||
getValues,
|
||||
setValue
|
||||
} = form;
|
||||
|
||||
const headerFields = useFieldArray({
|
||||
control,
|
||||
name: "credentials.headers"
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="credentials.url"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Endpoint URL"
|
||||
>
|
||||
<Input {...field} placeholder="https://example.com" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormLabel label="Headers" isOptional />
|
||||
{headerFields.fields.map(({ id: headerFieldId }, i) => (
|
||||
<div key={headerFieldId} className="flex space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`credentials.headers.${i}.key`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="w-1/3"
|
||||
>
|
||||
<Input {...field} placeholder="Authorization" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`credentials.headers.${i}.value`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Bearer <token>"
|
||||
autoComplete="new-password"
|
||||
onFocus={(e) => {
|
||||
if (
|
||||
auditLogStream &&
|
||||
auditLogStream.credentials.headers[i] &&
|
||||
auditLogStream.credentials.headers[i].value === "******" &&
|
||||
field.value === "******"
|
||||
) {
|
||||
field.onChange("");
|
||||
}
|
||||
e.target.type = "text";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (
|
||||
auditLogStream &&
|
||||
auditLogStream.credentials.headers[i] &&
|
||||
auditLogStream.credentials.headers[i].value === "******" &&
|
||||
field.value === ""
|
||||
) {
|
||||
field.onChange("******");
|
||||
}
|
||||
e.target.type = "password";
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
ariaLabel="delete key"
|
||||
className="h-9"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const header = getValues("credentials.headers");
|
||||
if (header && header?.length > 1) {
|
||||
headerFields.remove(i);
|
||||
} else {
|
||||
setValue("credentials.headers", [{ key: "", value: "" }]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
onClick={() => headerFields.append({ value: "", key: "" })}
|
||||
>
|
||||
Add Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Create Log Stream"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
import { TDatadogProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/datadog-provider";
|
||||
|
||||
type Props = {
|
||||
auditLogStream?: TDatadogProviderLogStream;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.literal(LogProvider.Datadog),
|
||||
credentials: z.object({
|
||||
url: z.string().url().trim().min(1).max(255),
|
||||
token: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-fA-F0-9]{32}$/, "Invalid Datadog API key format")
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const DATADOG_ENDPOINTS = {
|
||||
"Datadog US1": "https://http-intake.logs.datadoghq.com/api/v2/logs",
|
||||
"Datadog US3": "https://http-intake.logs.us3.datadoghq.com/api/v2/logs",
|
||||
"Datadog US5": "https://http-intake.logs.us5.datadoghq.com/api/v2/logs",
|
||||
"Datadog EU": "https://http-intake.logs.datadoghq.eu/api/v2/logs",
|
||||
"Datadog AP1": "https://http-intake.logs.ap1.datadoghq.com/api/v2/logs",
|
||||
"Datadog AP2": "https://http-intake.logs.ap2.datadoghq.com/api/v2/logs",
|
||||
"Datadog GovCloud (US1-FED)": "https://http-intake.logs.ddog-gov.com/api/v2/logs"
|
||||
};
|
||||
|
||||
export const DatadogProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(auditLogStream);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: auditLogStream ?? {
|
||||
provider: LogProvider.Datadog,
|
||||
credentials: {
|
||||
url: DATADOG_ENDPOINTS["Datadog US1"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="credentials.url"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Datadog Region"
|
||||
helperText={value}
|
||||
>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.entries(DATADOG_ENDPOINTS).map(([k, v]) => {
|
||||
return (
|
||||
<SelectItem value={v} key={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.token"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Datadog Token"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Create Log Stream"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, SecretInput } from "@app/components/v2";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
import { TSplunkProviderLogStream } from "@app/hooks/api/auditLogStreams/types/providers/splunk-provider";
|
||||
|
||||
type Props = {
|
||||
auditLogStream?: TSplunkProviderLogStream;
|
||||
onSubmit: (formData: FormData) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.literal(LogProvider.Splunk),
|
||||
credentials: z.object({
|
||||
hostname: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.includes("://")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hostname should not include protocol"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`https://${val}`);
|
||||
if (url.hostname !== val) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid hostname without port or path"
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid hostname" });
|
||||
}
|
||||
}),
|
||||
token: z.string().uuid().trim().min(1)
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const SplunkProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
|
||||
const isUpdate = Boolean(auditLogStream);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: auditLogStream ?? {
|
||||
provider: LogProvider.Splunk
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="credentials.hostname"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Hostname"
|
||||
>
|
||||
<Input {...field} placeholder="splunk.example.com" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.token"
|
||||
control={control}
|
||||
shouldUnregister
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Splunk Token"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
{isUpdate ? "Update Credentials" : "Create Log Stream"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
+17
-150
@@ -1,72 +1,24 @@
|
||||
import { faPlug, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects,
|
||||
useOrganization,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteAuditLogStream, useGetAuditLogStreams } from "@app/hooks/api";
|
||||
|
||||
import { AuditLogStreamForm } from "./AuditLogStreamForm";
|
||||
import { AuditLogStreamTable } from "./components/AuditLogStreamTable";
|
||||
import { AddAuditLogStreamModal } from "./components";
|
||||
|
||||
export const AuditLogStreamsTab = withPermission(
|
||||
() => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"auditLogStreamForm",
|
||||
"deleteAuditLogStream",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data: auditLogStreams, isPending: isAuditLogStreamsLoading } =
|
||||
useGetAuditLogStreams(orgId);
|
||||
|
||||
// mutation
|
||||
const { mutateAsync: deleteAuditLogStream } = useDeleteAuditLogStream();
|
||||
|
||||
const handleAuditLogStreamDelete = async () => {
|
||||
try {
|
||||
const auditLogStreamId = popUp?.deleteAuditLogStream?.data as string;
|
||||
await deleteAuditLogStream({
|
||||
id: auditLogStreamId,
|
||||
orgId
|
||||
});
|
||||
handlePopUpClose("deleteAuditLogStream");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted stream"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete stream"
|
||||
});
|
||||
}
|
||||
};
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"auditLogStreamForm",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
@@ -84,8 +36,10 @@ export const AuditLogStreamsTab = withPermission(
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Create
|
||||
Add Log Stream
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
@@ -93,102 +47,15 @@ export const AuditLogStreamsTab = withPermission(
|
||||
<p className="mb-8 text-gray-400">
|
||||
Send audit logs from Infisical to external logging providers via HTTP
|
||||
</p>
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Td>URL</Td>
|
||||
<Td className="text-right">Action</Td>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isAuditLogStreamsLoading && (
|
||||
<TableSkeleton columns={2} innerKey="stream-loading" />
|
||||
)}
|
||||
{!isAuditLogStreamsLoading && auditLogStreams && auditLogStreams?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState title="No audit log streams found" icon={faPlug} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isAuditLogStreamsLoading &&
|
||||
auditLogStreams?.map(({ id, url }) => (
|
||||
<Tr key={id}>
|
||||
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
|
||||
{url}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Settings}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handlePopUpOpen("auditLogStreamForm", id)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Settings}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
|
||||
colorSchema="danger"
|
||||
size="xs"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handlePopUpOpen("deleteAuditLogStream", id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
<Modal
|
||||
<AuditLogStreamTable />
|
||||
<AddAuditLogStreamModal
|
||||
isOpen={popUp.auditLogStreamForm.isOpen}
|
||||
onOpenChange={(isModalOpen) => {
|
||||
handlePopUpToggle("auditLogStreamForm", isModalOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={`${popUp?.auditLogStreamForm?.data ? "Update" : "Create"} Audit Log Stream `}
|
||||
subTitle="Continuously stream logs from Infisical to third-party logging providers."
|
||||
>
|
||||
<AuditLogStreamForm
|
||||
id={popUp?.auditLogStreamForm?.data as string}
|
||||
onClose={() => handlePopUpToggle("auditLogStreamForm")}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("auditLogStreamForm", isOpen)}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteAuditLogStream.isOpen}
|
||||
deleteKey="delete"
|
||||
title="Are you sure you want to remove this stream?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteAuditLogStream", isOpen)}
|
||||
onClose={() => handlePopUpClose("deleteAuditLogStream")}
|
||||
onDeleteApproved={handleAuditLogStreamDelete}
|
||||
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
import { TAuditLogStream } from "@app/hooks/api/types";
|
||||
|
||||
import { AuditLogStreamForm } from "../AuditLogStreamForm/AuditLogStreamForm";
|
||||
import { LogStreamProviderSelect } from "./LogStreamProviderSelect";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onComplete: (auditLogStream: TAuditLogStream) => void;
|
||||
selectedProvider: LogProvider | null;
|
||||
setSelectedProvider: Dispatch<SetStateAction<LogProvider | null>>;
|
||||
};
|
||||
|
||||
const Content = ({ onComplete, selectedProvider, setSelectedProvider }: ContentProps) => {
|
||||
if (selectedProvider) {
|
||||
return (
|
||||
<AuditLogStreamForm
|
||||
onComplete={onComplete}
|
||||
onBack={() => setSelectedProvider(null)}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <LogStreamProviderSelect onSelect={setSelectedProvider} />;
|
||||
};
|
||||
|
||||
export const AddAuditLogStreamModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState<LogProvider | null>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(e) => {
|
||||
onOpenChange(e);
|
||||
if (!e) setSelectedProvider(null);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Log Provider"
|
||||
subTitle=<>
|
||||
Select a log provider or{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => setSelectedProvider(LogProvider.Custom)}
|
||||
>
|
||||
input a custom URL
|
||||
</button>{" "}
|
||||
to stream logs to.
|
||||
</>
|
||||
>
|
||||
<Content
|
||||
onComplete={() => onOpenChange(false)}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
|
||||
type Props = {
|
||||
provider: LogProvider;
|
||||
logStreamExists: boolean;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export const AuditLogStreamHeader = ({ provider, logStreamExists, onBack }: Props) => {
|
||||
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full items-center gap-2 border-b border-mineshaft-500 pb-4">
|
||||
<div className="relative">
|
||||
{providerDetails.image ? (
|
||||
<img
|
||||
alt={providerDetails.name}
|
||||
src={`/images/integrations/${providerDetails.image}`}
|
||||
className="size-12 rounded-md bg-bunker-500 p-2"
|
||||
/>
|
||||
) : (
|
||||
providerDetails.icon && (
|
||||
<div className="size-12 rounded-md bg-bunker-500 p-2">
|
||||
<FontAwesomeIcon
|
||||
icon={providerDetails.icon}
|
||||
className="h-full w-full text-mineshaft-300"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center text-mineshaft-300">
|
||||
{providerDetails.name}
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/audit-log-streams/audit-log-streams#example-providers"
|
||||
target="_blank"
|
||||
className="ml-1"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mb-px mr-1 text-xs" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="mb-px ml-1 text-[10px]" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm leading-4 text-mineshaft-400">
|
||||
{logStreamExists
|
||||
? `${providerDetails.name} Log Stream`
|
||||
: `Create a ${providerDetails.name} Log Stream`}
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto mt-1 text-xs text-mineshaft-400 underline underline-offset-2 hover:text-mineshaft-300"
|
||||
onClick={onBack}
|
||||
>
|
||||
Select another provider
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { faAsterisk, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionSubjects } from "@app/context";
|
||||
import { OrgPermissionActions } from "@app/context/OrgPermissionContext/types";
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP, getProviderUrl } from "@app/helpers/auditLogStreams";
|
||||
import { TAuditLogStream } from "@app/hooks/api/types";
|
||||
|
||||
type Props = {
|
||||
logStream: TAuditLogStream;
|
||||
onDelete: (logStream: TAuditLogStream) => void;
|
||||
onEditCredentials: (logStream: TAuditLogStream) => void;
|
||||
};
|
||||
|
||||
export const AuditLogStreamRow = ({ logStream, onDelete, onEditCredentials }: Props) => {
|
||||
const { id, provider } = logStream;
|
||||
|
||||
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
|
||||
const url = getProviderUrl(logStream);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
|
||||
key={`log-stream-${id}`}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
{providerDetails.image ? (
|
||||
<img
|
||||
alt={providerDetails.name}
|
||||
src={`/images/integrations/${providerDetails.image}`}
|
||||
className="size-5"
|
||||
/>
|
||||
) : (
|
||||
providerDetails.icon && (
|
||||
<FontAwesomeIcon
|
||||
icon={providerDetails.icon}
|
||||
className="size-5 text-mineshaft-300"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden lg:inline">{providerDetails.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<div className="flex w-full items-center">
|
||||
<p className="truncate">{url}</p>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faAsterisk} />}
|
||||
onClick={() => onEditCredentials(logStream)}
|
||||
>
|
||||
Edit Credentials
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Settings}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={() => onDelete(logStream)}
|
||||
>
|
||||
Delete Stream
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPlug,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP, getProviderUrl } from "@app/helpers/auditLogStreams";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useListAuditLogStreams } from "@app/hooks/api";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { TAuditLogStream } from "@app/hooks/api/types";
|
||||
|
||||
import { AuditLogStreamRow } from "./AuditLogStreamRow";
|
||||
import { DeleteAuditLogStreamModal } from "./DeleteAuditLogStreamModal";
|
||||
import { EditAuditLogStreamCredentialsModal } from "./EditAuditLogStreamCredentialsModal";
|
||||
|
||||
enum LogStreamsOrderBy {
|
||||
Provider = "provider",
|
||||
Url = "url"
|
||||
}
|
||||
|
||||
type LogStreamFilters = {
|
||||
providers: LogProvider[];
|
||||
};
|
||||
|
||||
export const AuditLogStreamTable = () => {
|
||||
const { isPending, data: logStreams = [] } = useListAuditLogStreams();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"delete",
|
||||
"editCredentials"
|
||||
] as const);
|
||||
|
||||
const [filters, setFilters] = useState<LogStreamFilters>({
|
||||
providers: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<LogStreamsOrderBy>(LogStreamsOrderBy.Provider, {
|
||||
initPerPage: getUserTablePreference("logStreamsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("logStreamsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredLogStreams = useMemo(
|
||||
() =>
|
||||
logStreams
|
||||
.filter((stream) => {
|
||||
const { provider } = stream;
|
||||
|
||||
if (filters.providers.length && !filters.providers.includes(provider)) return false;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return AUDIT_LOG_STREAM_PROVIDER_MAP[provider].name.toLowerCase().includes(searchValue);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case LogStreamsOrderBy.Url:
|
||||
return getProviderUrl(one)
|
||||
.toLowerCase()
|
||||
.localeCompare(getProviderUrl(two).toLowerCase());
|
||||
case LogStreamsOrderBy.Provider:
|
||||
default:
|
||||
return AUDIT_LOG_STREAM_PROVIDER_MAP[one.provider].name
|
||||
.toLowerCase()
|
||||
.localeCompare(AUDIT_LOG_STREAM_PROVIDER_MAP[two.provider].name.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[logStreams, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredLogStreams.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: LogStreamsOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: LogStreamsOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: LogStreamsOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered = Boolean(filters.providers.length);
|
||||
|
||||
const handleDelete = (logStream: TAuditLogStream) => handlePopUpOpen("delete", logStream);
|
||||
|
||||
const handleEditCredentials = (logStream: TAuditLogStream) => {
|
||||
handlePopUpOpen("editCredentials", logStream);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search audit log streams..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Log Streams"
|
||||
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 className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Filter by Provider</DropdownMenuLabel>
|
||||
{logStreams.length ? (
|
||||
[...new Set(logStreams.map(({ provider }) => provider))].map((provider) => {
|
||||
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
providers: prev.providers.includes(provider)
|
||||
? prev.providers.filter((a) => a !== provider)
|
||||
: [...prev.providers, provider]
|
||||
}));
|
||||
}}
|
||||
key={provider}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{providerDetails.image ? (
|
||||
<img
|
||||
alt={providerDetails.name}
|
||||
src={`/images/integrations/${providerDetails.image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
) : (
|
||||
providerDetails.icon && (
|
||||
<FontAwesomeIcon
|
||||
icon={providerDetails.icon}
|
||||
className="size-4 text-mineshaft-300"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<span>{providerDetails.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Providers Configured</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/4">
|
||||
<div className="flex items-center">
|
||||
Provider
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(LogStreamsOrderBy.Provider)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(LogStreamsOrderBy.Provider)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(LogStreamsOrderBy.Provider)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Endpoint URL
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(LogStreamsOrderBy.Url)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(LogStreamsOrderBy.Url)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(LogStreamsOrderBy.Url)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && (
|
||||
<TableSkeleton
|
||||
innerKey="audit-log-streams-table"
|
||||
columns={3}
|
||||
key="audit-log-streams"
|
||||
/>
|
||||
)}
|
||||
{filteredLogStreams.slice(offset, perPage * page).map((stream) => (
|
||||
<AuditLogStreamRow
|
||||
logStream={stream}
|
||||
key={stream.id}
|
||||
onDelete={handleDelete}
|
||||
onEditCredentials={handleEditCredentials}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredLogStreams.length) && (
|
||||
<Pagination
|
||||
count={filteredLogStreams.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredLogStreams?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
logStreams.length
|
||||
? "No log streams match search..."
|
||||
: "No log streams have been configured"
|
||||
}
|
||||
icon={logStreams.length ? faSearch : faPlug}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<DeleteAuditLogStreamModal
|
||||
isOpen={popUp.delete.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("delete", isOpen)}
|
||||
auditLogStream={popUp.delete.data}
|
||||
/>
|
||||
<EditAuditLogStreamCredentialsModal
|
||||
isOpen={popUp.editCredentials.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)}
|
||||
auditLogStream={popUp.editCredentials.data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
|
||||
import { useDeleteAuditLogStream } from "@app/hooks/api";
|
||||
import { TAuditLogStream } from "@app/hooks/api/types";
|
||||
|
||||
type Props = {
|
||||
auditLogStream?: TAuditLogStream;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DeleteAuditLogStreamModal = ({ isOpen, onOpenChange, auditLogStream }: Props) => {
|
||||
const deleteAuditLogStream = useDeleteAuditLogStream();
|
||||
|
||||
if (!auditLogStream) return null;
|
||||
|
||||
const { id: auditLogStreamId, provider } = auditLogStream;
|
||||
|
||||
const providerDetails = AUDIT_LOG_STREAM_PROVIDER_MAP[provider];
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteAuditLogStream.mutateAsync({
|
||||
auditLogStreamId,
|
||||
provider
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully deleted ${providerDetails.name} stream`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
createNotification({
|
||||
text: `Failed to delete ${providerDetails.name} stream`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteActionModal
|
||||
isOpen={isOpen}
|
||||
onChange={onOpenChange}
|
||||
title="Are you sure you want to delete this log stream?"
|
||||
deleteKey="delete"
|
||||
onDeleteApproved={handleDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
|
||||
import { TAuditLogStream } from "@app/hooks/api/types";
|
||||
|
||||
import { AuditLogStreamForm } from "../AuditLogStreamForm/AuditLogStreamForm";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
auditLogStream?: TAuditLogStream;
|
||||
};
|
||||
|
||||
export const EditAuditLogStreamCredentialsModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
auditLogStream
|
||||
}: Props) => {
|
||||
if (!auditLogStream) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Edit Log Stream Credentials"
|
||||
subTitle={`Update the credentials for this ${AUDIT_LOG_STREAM_PROVIDER_MAP[auditLogStream.provider].name} Log Stream.`}
|
||||
>
|
||||
<AuditLogStreamForm
|
||||
onComplete={() => onOpenChange(false)}
|
||||
auditLogStream={auditLogStream}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { useMemo } from "react";
|
||||
import { faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { EmptyState, Spinner } from "@app/components/v2";
|
||||
import { AUDIT_LOG_STREAM_PROVIDER_MAP } from "@app/helpers/auditLogStreams";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetAuditLogStreamOptions } from "@app/hooks/api";
|
||||
import { LogProvider } from "@app/hooks/api/auditLogStreams/enums";
|
||||
|
||||
type Props = {
|
||||
onSelect: (provider: LogProvider) => void;
|
||||
};
|
||||
|
||||
// TODO: When we have more than 1 page of providers, uncomment the search components
|
||||
|
||||
export const LogStreamProviderSelect = ({ onSelect }: Props) => {
|
||||
const { isPending, data: logStreamOptions } = useGetAuditLogStreamOptions();
|
||||
|
||||
const { search, setPage, page, perPage, offset } = usePagination("", {
|
||||
initPerPage: 16
|
||||
});
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
(logStreamOptions || [])
|
||||
.filter(
|
||||
({ name, provider }) =>
|
||||
name.toLowerCase().includes(search.trim().toLowerCase()) ||
|
||||
provider.toLowerCase().includes(search.trim().toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.provider === LogProvider.Custom) return 1;
|
||||
if (b.provider === LogProvider.Custom) return -1;
|
||||
return 0;
|
||||
}),
|
||||
[logStreamOptions, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredOptions.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center py-2.5">
|
||||
<Spinner size="lg" className="text-mineshaft-500" />
|
||||
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* <Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search options..."
|
||||
className="bg-mineshaft-800 placeholder:text-mineshaft-400"
|
||||
/> */}
|
||||
<div className="grid h-[29.5rem] grid-cols-4 content-start gap-2">
|
||||
{filteredOptions.slice(offset, perPage * page)?.map((option) => {
|
||||
const { image, icon, name, size = 50 } = AUDIT_LOG_STREAM_PROVIDER_MAP[option.provider];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(option.provider)}
|
||||
className={`group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 ${option.provider === LogProvider.Custom ? "bg-mineshaft-700/30 hover:bg-mineshaft-600/30" : "bg-mineshaft-700 hover:bg-mineshaft-600"} p-4 duration-200`}
|
||||
>
|
||||
{image && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`/images/integrations/${image}`}
|
||||
style={{
|
||||
width: `${size}px`
|
||||
}}
|
||||
className="mt-auto"
|
||||
alt={`${name} logo`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{icon && (
|
||||
<FontAwesomeIcon className="mt-auto size-10 text-mineshaft-300" icon={icon} />
|
||||
)}
|
||||
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{name}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filteredOptions.length && (
|
||||
<EmptyState
|
||||
className="col-span-full mt-40"
|
||||
title="No providers match search"
|
||||
icon={faSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* {Boolean(filteredOptions.length) && (
|
||||
<Pagination
|
||||
startAdornment={
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
className="max-w-sm py-4"
|
||||
content={
|
||||
<>
|
||||
<p className="mb-2">Infisical is constantly adding support for more providers.</p>
|
||||
<p>
|
||||
{`If you don't see the third-party
|
||||
provider you're looking for,`}{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline hover:text-mineshaft-300"
|
||||
href="https://infisical.com/slack"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
let us know on Slack
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline hover:text-mineshaft-300"
|
||||
href="https://github.com/Infisical/infisical/discussions"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
make a request on GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="-ml-3 flex items-center gap-1.5 text-mineshaft-400">
|
||||
<span className="text-xs">
|
||||
Don't see the third-party provider you're looking for?
|
||||
</span>
|
||||
<FontAwesomeIcon size="xs" icon={faInfoCircle} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
count={filteredOptions.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
perPageList={[16]}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./AddAuditLogStreamModal";
|
||||
+45
-12
@@ -15,7 +15,9 @@ import {
|
||||
OrgPermissionSubjects
|
||||
} from "@app/context/OrgPermissionContext/types";
|
||||
import { gatewaysQueryKeys } from "@app/hooks/api";
|
||||
import { useHasCustomMigrationAvailable } from "@app/hooks/api/migration";
|
||||
import { useImportVault } from "@app/hooks/api/migration/mutations";
|
||||
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
@@ -24,11 +26,13 @@ type Props = {
|
||||
|
||||
enum VaultMappingType {
|
||||
KeyVault = "key-vault",
|
||||
Namespace = "namespace"
|
||||
Namespace = "namespace",
|
||||
Custom = "custom"
|
||||
}
|
||||
|
||||
const MAPPING_TYPE_MENU_ITEMS = [
|
||||
{
|
||||
isCustom: false,
|
||||
value: VaultMappingType.KeyVault,
|
||||
label: "Key Vaults",
|
||||
tooltip: (
|
||||
@@ -48,6 +52,7 @@ const MAPPING_TYPE_MENU_ITEMS = [
|
||||
)
|
||||
},
|
||||
{
|
||||
isCustom: false,
|
||||
value: VaultMappingType.Namespace,
|
||||
label: "Namespaces",
|
||||
tooltip: (
|
||||
@@ -63,10 +68,25 @@ const MAPPING_TYPE_MENU_ITEMS = [
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
isCustom: true,
|
||||
value: VaultMappingType.Custom,
|
||||
label: "Custom Migration",
|
||||
tooltip: (
|
||||
<div>
|
||||
Custom migrations allow you to shape your Vault migration to your specific needs. Please
|
||||
contact our sales team to get started with custom migrations.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||
const { data: isCustomMigrationAvailable } = useHasCustomMigrationAvailable(
|
||||
ExternalMigrationProviders.Vault
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
vaultUrl: z.string().min(1),
|
||||
gatewayId: z.string().optional(),
|
||||
@@ -230,31 +250,44 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="mt-2 grid h-full w-full grid-cols-2 gap-4">
|
||||
<div className="mt-2 grid grid-cols-2 gap-4">
|
||||
{MAPPING_TYPE_MENU_ITEMS.map((el) => (
|
||||
<div
|
||||
key={el.value}
|
||||
className={twMerge(
|
||||
"flex w-full cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all",
|
||||
field.value === el.value
|
||||
? "border-primary-700 border-opacity-70 bg-mineshaft-600 opacity-100"
|
||||
: "hover:border-primary-700 hover:bg-mineshaft-600"
|
||||
? "border-primary-700 border-opacity-70 bg-mineshaft-700 opacity-100"
|
||||
: "hover:border-primary-800/75 hover:bg-mineshaft-600",
|
||||
el.isCustom && "col-span-2",
|
||||
el.isCustom &&
|
||||
!isCustomMigrationAvailable?.data?.enabled &&
|
||||
"!cursor-not-allowed !border-mineshaft-600 !bg-mineshaft-600 !opacity-40"
|
||||
)}
|
||||
onClick={() => field.onChange(el.value)}
|
||||
onClick={() => {
|
||||
if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return;
|
||||
|
||||
field.onChange(el.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return;
|
||||
|
||||
field.onChange(el.value);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
field.onChange(el.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-center text-sm">{el.label}</div>
|
||||
{el.tooltip && (
|
||||
<div className="text-center text-sm">
|
||||
<Tooltip content={el.tooltip} className="max-w-96">
|
||||
<FontAwesomeIcon className="opacity-60" icon={faQuestionCircle} />
|
||||
<FontAwesomeIcon
|
||||
size="sm"
|
||||
className="text-mineshaft-400"
|
||||
icon={faQuestionCircle}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@@ -272,7 +305,7 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isDirty || isSubmitting || isLoading || !isValid}
|
||||
>
|
||||
Import data
|
||||
Import Data
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
|
||||
+3
-2
@@ -204,9 +204,10 @@ export const OrgGeneralAuthSection = ({
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
Enforce users to authenticate via Google to access this organization.
|
||||
Enforce users to authenticate via Google OAuth SSO to access this organization.
|
||||
<br />
|
||||
When this is enabled your organization members will only be able to login with Google.
|
||||
When this is enabled your organization members will only be able to login with Google
|
||||
SSO (not Google SAML).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ export const AccessApprovalRequest = ({
|
||||
}) => {
|
||||
const [selectedRequest, setSelectedRequest] = useState<
|
||||
| (TAccessApprovalRequest & {
|
||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isSelfApproveAllowed: boolean;
|
||||
isApprover: boolean;
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export const ReviewAccessRequestModal = ({
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
request: TAccessApprovalRequest & {
|
||||
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isSelfApproveAllowed: boolean;
|
||||
isApprover: boolean;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretRotationActions
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
@@ -106,7 +107,7 @@ const Page = () => {
|
||||
});
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { mutateAsync: createCommit } = useCreateCommit();
|
||||
const { mutateAsync: createCommit, isPending: isCommitPending } = useCreateCommit();
|
||||
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
|
||||
@@ -213,6 +214,11 @@ const Page = () => {
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
const canReadCommits = permission.can(
|
||||
ProjectPermissionCommitsActions.Read,
|
||||
ProjectPermissionSub.Commits
|
||||
);
|
||||
|
||||
const defaultFilterState = {
|
||||
tags: {},
|
||||
searchFilter: (routerQueryParams.search as string) || "",
|
||||
@@ -377,7 +383,7 @@ const Page = () => {
|
||||
directory: secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
isPaused: !canDoReadRollback
|
||||
isPaused: !canReadCommits
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -997,7 +1003,7 @@ const Page = () => {
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isCommitting={false}
|
||||
isCommitting={isCommitPending}
|
||||
/>
|
||||
)}
|
||||
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||
|
||||
+3
-3
@@ -75,7 +75,7 @@ type Props = {
|
||||
onCreateTag: () => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
handleSecretShare: () => void;
|
||||
onShareSecret: (sec: SecretV3RawSanitized) => void;
|
||||
importedBy?: {
|
||||
environment: { name: string; slug: string };
|
||||
folders: {
|
||||
@@ -102,7 +102,7 @@ export const SecretItem = memo(
|
||||
onToggleSecretSelect,
|
||||
environment,
|
||||
secretPath,
|
||||
handleSecretShare,
|
||||
onShareSecret,
|
||||
importedBy,
|
||||
isPending,
|
||||
pendingAction,
|
||||
@@ -682,7 +682,7 @@ export const SecretItem = memo(
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="share-secret"
|
||||
onClick={handleSecretShare}
|
||||
onClick={() => onShareSecret(secret)}
|
||||
>
|
||||
<Tooltip content="Share Secret">
|
||||
<FontAwesomeSymbol
|
||||
|
||||
+19
-16
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -100,6 +100,11 @@ export const SecretListView = ({
|
||||
});
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
const pendingChangesRef = useRef(pendingChanges);
|
||||
useEffect(() => {
|
||||
pendingChangesRef.current = pendingChanges;
|
||||
}, [pendingChanges]);
|
||||
|
||||
const handleSecretOperation = async (
|
||||
operation: "create" | "update" | "delete",
|
||||
type: SecretType,
|
||||
@@ -337,7 +342,10 @@ export const SecretListView = ({
|
||||
secretPath
|
||||
});
|
||||
} else {
|
||||
const trueOriginalSecret = getTrueOriginalSecret(orgSecret, pendingChanges.secrets);
|
||||
const trueOriginalSecret = getTrueOriginalSecret(
|
||||
orgSecret,
|
||||
pendingChangesRef.current.secrets
|
||||
);
|
||||
|
||||
const updateChange: PendingSecretUpdate = {
|
||||
id: orgSecret.id,
|
||||
@@ -442,15 +450,7 @@ export const SecretListView = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
environment,
|
||||
secretPath,
|
||||
isProtectedBranch,
|
||||
isBatchMode,
|
||||
workspaceId,
|
||||
addPendingChange,
|
||||
pendingChanges.secrets
|
||||
]
|
||||
[environment, secretPath, isProtectedBranch, isBatchMode, workspaceId, addPendingChange]
|
||||
);
|
||||
|
||||
const handleSecretDelete = useCallback(async () => {
|
||||
@@ -535,6 +535,13 @@ export const SecretListView = ({
|
||||
(sec: SecretV3RawSanitized) => handlePopUpOpen("secretDetail", sec),
|
||||
[]
|
||||
);
|
||||
const onShareSecret = useCallback(
|
||||
(sec: SecretV3RawSanitized) =>
|
||||
handlePopUpOpen("createSharedSecret", {
|
||||
value: sec.valueOverride ?? sec.value
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -557,11 +564,7 @@ export const SecretListView = ({
|
||||
onDetailViewSecret={onDetailViewSecret}
|
||||
importedBy={importedBy}
|
||||
onCreateTag={onCreateTag}
|
||||
handleSecretShare={() =>
|
||||
handlePopUpOpen("createSharedSecret", {
|
||||
value: secret.valueOverride ?? secret.value
|
||||
})
|
||||
}
|
||||
onShareSecret={onShareSecret}
|
||||
isPending={secret.isPending}
|
||||
pendingAction={secret.pendingAction}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user