Merge remote-tracking branch 'origin/main' into feat/gateway-v2

This commit is contained in:
Sheen Capadngan
2025-09-09 02:14:49 +08:00
188 changed files with 12002 additions and 6900 deletions
@@ -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";
@@ -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";
@@ -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>
);
};
@@ -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>
);
};
@@ -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";
@@ -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") ||
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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}
/>
);
};
@@ -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>
);
};
@@ -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&#39;t see the third-party provider you&#39;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>
);
};
@@ -0,0 +1 @@
export * from "./AddAuditLogStreamModal";
@@ -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
@@ -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>
@@ -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;
@@ -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} />}
@@ -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
@@ -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}
/>