feat(pam): PAM Platform V1

This commit is contained in:
x032205
2025-09-27 01:57:43 -04:00
parent 270d8237fe
commit 4b1664c30f
141 changed files with 9327 additions and 46 deletions
@@ -0,0 +1,34 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types";
import { PamAccountsSection } from "./components/PamAccountsSection";
export const PamAccountsPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PAM" })}</title>
</Helmet>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionPamAccountActions.Read}
a={ProjectPermissionSub.PamAccounts}
>
<div className="h-full bg-bunker-800">
<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="Accounts" description="View, access, and manage accounts." />
<PamAccountsSection />
</div>
</div>
</div>
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,42 @@
import { Button } from "@app/components/v2";
export enum AccountView {
Flat = "flat",
Nested = "nested"
}
type Props = {
value: AccountView;
onChange: (value: AccountView) => void;
};
export const AccountViewToggle = ({ value, onChange }: Props) => {
return (
<div className="flex gap-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Button
variant="outline_bg"
onClick={() => {
onChange(AccountView.Flat);
}}
size="xs"
className={`${
value === AccountView.Flat ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Hide Folders
</Button>
<Button
variant="outline_bg"
onClick={() => {
onChange(AccountView.Nested);
}}
size="xs"
className={`${
value === AccountView.Nested ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600`}
>
Show Folders
</Button>
</div>
);
};
@@ -0,0 +1,89 @@
import { useMemo, useState } from "react";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { createNotification } from "@app/components/notifications";
import { Button, FormLabel, Input, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { TPamAccount } from "@app/hooks/api/pam";
type Props = {
account?: TPamAccount;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
const [duration, setDuration] = useState("4h");
const isDurationValid = useMemo(() => ms(duration || "1s") > 0, [duration]);
const command = useMemo(
() =>
account
? `infisical pam access ${account.id}${duration ? ` --duration ${duration}` : ""}`
: "",
[account, duration]
);
if (!account) return null;
const copyCommand = () => {
navigator.clipboard.writeText(command);
createNotification({
text: "Command copied to clipboard",
type: "info"
});
onOpenChange(false);
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Access Account"
subTitle={`Access ${account.name} using a CLI command.`}
>
<FormLabel
label="Duration"
tooltipText="The maximum duration of your session. Ex: 1h, 3w, 30d"
/>
<Input
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="permanent"
isError={!isDurationValid}
/>
<FormLabel label="CLI Command" className="mt-4" />
<Input value={command} isDisabled />
<a
href="https://infisical.com/docs/cli/overview"
target="_blank"
className="mt-2 flex h-4 w-fit items-center gap-2 border-b border-mineshaft-400 text-sm text-mineshaft-400 transition-colors duration-100 hover:border-yellow-400 hover:text-yellow-400"
rel="noreferrer"
>
<span>Install the Infisical CLI</span>
<FontAwesomeIcon icon={faUpRightFromSquare} className="size-3" />
</a>
<div className="mt-6 flex items-center">
<Button
isDisabled={!isDurationValid}
className="mr-4"
size="sm"
colorSchema="secondary"
onClick={copyCommand}
>
Copy Command
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,49 @@
import { Controller, useFormContext } from "react-hook-form";
import { z } from "zod";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAccountFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().max(512).nullable().optional()
});
export const GenericAccountFields = () => {
const {
formState: { errors },
control
} = useFormContext<{ name: string; description: string }>();
return (
<>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-account" {...field} />
</FormControl>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<FormControl
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Description"
>
<TextArea {...field} />
</FormControl>
)}
/>
</>
);
};
@@ -0,0 +1,147 @@
import { createNotification } from "@app/components/notifications";
import {
PamResourceType,
TPamAccount,
useCreatePamAccount,
useUpdatePamAccount
} from "@app/hooks/api/pam";
import { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader";
import { PostgresAccountForm } from "./PostgresAccountForm";
type FormProps = {
onComplete: (account: TPamAccount) => void;
};
type CreateFormProps = FormProps & {
projectId: string;
resourceId: string;
resourceType: PamResourceType;
folderId?: string;
};
type UpdateFormProps = FormProps & {
account: TPamAccount;
};
const CreateForm = ({
onComplete,
projectId,
resourceId,
resourceType,
folderId
}: CreateFormProps) => {
const createPamAccount = useCreatePamAccount();
console.log({ folderId });
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
) => {
try {
const account = await createPamAccount.mutateAsync({
...formData,
folderId,
resourceId,
resourceType,
projectId
});
createNotification({
text: "Successfully created account",
type: "success"
});
onComplete(account);
} catch (err: any) {
console.error(err);
createNotification({
title: "Failed to create account",
text: err.message,
type: "error"
});
}
};
switch (resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
};
const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
const updatePamAccount = useUpdatePamAccount();
const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
) => {
try {
const updatedAccount = await updatePamAccount.mutateAsync({
accountId: account.id,
resourceId: account.resourceId,
resourceType: account.resource.resourceType,
...formData
});
createNotification({
text: "Successfully updated account",
type: "success"
});
onComplete(updatedAccount);
} catch (err: any) {
console.error(err);
createNotification({
title: "Failed to update account",
text: err.message,
type: "error"
});
}
};
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}
};
type Props = {
onBack?: () => void;
projectId: string;
} & FormProps &
(
| {
account: TPamAccount;
resourceId?: undefined;
resourceName?: undefined;
resourceType?: undefined;
folderId?: undefined;
}
| {
account?: undefined;
resourceId: string;
resourceName: string;
resourceType: PamResourceType;
folderId?: string;
}
);
export const PamAccountForm = ({ onBack, projectId, ...props }: Props) => {
const { account, resourceName, resourceType } = props;
return (
<div>
<PamAccountHeader
resourceName={account ? account.resource.name : resourceName}
resourceType={account ? account.resource.resourceType : resourceType}
onBack={onBack}
/>
{account ? (
<UpdateForm {...props} account={account} />
) : (
<CreateForm {...props} projectId={projectId} />
)}
</div>
);
};
@@ -0,0 +1,73 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { TPostgresAccount } from "@app/hooks/api/pam";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
type Props = {
account?: TPostgresAccount;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSqlAccountSchema
});
type FormData = z.infer<typeof formSchema>;
export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: account
? {
...account,
credentials: {
...account.credentials,
password: "******"
}
}
: undefined
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Account" : "Create Account"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,55 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2";
export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control } = useFormContext();
return (
<div className="flex gap-2">
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="credentials.password"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
>
<Input
{...field}
type="password"
onFocus={(e) => {
if (isUpdate && field.value === "******") {
field.onChange("");
}
e.target.type = "text";
}}
onBlur={(e) => {
if (isUpdate && field.value === "") {
field.onChange("******");
}
e.target.type = "password";
}}
/>
</FormControl>
)}
/>
</div>
);
};
@@ -0,0 +1,6 @@
import { z } from "zod";
export const BaseSqlAccountSchema = z.object({
username: z.string().trim().min(1, "Username required"),
password: z.string().trim().min(1, "Password required")
});
@@ -0,0 +1,34 @@
import { PAM_RESOURCE_TYPE_MAP, PamResourceType } from "@app/hooks/api/pam";
type Props = {
resourceName: string;
resourceType: PamResourceType;
onBack?: () => void;
};
export const PamAccountHeader = ({ resourceName, resourceType, onBack }: Props) => {
const details = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
<img
alt={`${details.name} logo`}
src={`/images/integrations/${details.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">{resourceName}</div>
<p className="text-sm leading-4 text-mineshaft-400">{details.name} resource</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 resource
</button>
)}
</div>
);
};
@@ -0,0 +1,159 @@
import { useCallback } from "react";
import {
faBoxOpen,
faCheck,
faCopy,
faEdit,
faEllipsisV,
faRightToBracket,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount } from "@app/hooks/api/pam";
type Props = {
account: TPamAccount;
onAccess: (resource: TPamAccount) => void;
onUpdate: (resource: TPamAccount) => void;
onDelete: (resource: TPamAccount) => void;
search: string;
};
export const PamAccountRow = ({ account, search, onAccess, onUpdate, onDelete }: Props) => {
const { id, name } = account;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[account.resource.resourceType];
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(
(idToCopy: string) => {
setIsIdCopied.on();
navigator.clipboard.writeText(idToCopy);
createNotification({
text: "Account ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
},
[isIdCopied]
);
return (
<Tr className={twMerge("group h-10")} key={`account-${id}`}>
<Td>
<div className="flex items-center gap-4">
<div className="relative">
<img alt={resourceTypeName} src={`/images/integrations/${image}`} className="size-5" />
</div>
<div className="flex items-center gap-3">
<span>
<HighlightText text={name} highlight={search} />
</span>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-bunker-300/20 text-bunker-300">
<FontAwesomeIcon icon={faBoxOpen} />
<span>
<HighlightText text={account.resource.name} highlight={search} />
</span>
</Badge>
</div>
</div>
</Td>
<Td>
<div className="flex items-center gap-2">
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Access}
a={ProjectPermissionSub.PamAccounts}
>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faRightToBracket} />}
onClick={() => onAccess(account)}
size="xs"
>
Access
</Button>
</ProjectPermissionCan>
<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">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId(id);
}}
>
Copy Account ID
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Edit}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onUpdate(account)}
>
Edit Account
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Delete}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(account)}
>
Delete Account
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
);
};
@@ -0,0 +1,23 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamAccounts } from "@app/hooks/api/pam";
import { PamAccountsTable } from "./PamAccountsTable";
export const PamAccountsSection = () => {
const { currentProject } = useProject();
const { data, isPending } = useListPamAccounts(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return (
<PamAccountsTable
projectId={currentProject.id}
accounts={data?.accounts || []}
folders={data?.folders || []}
/>
);
};
@@ -0,0 +1,501 @@
import { useMemo, useState } from "react";
import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faFolderPlus,
faMagnifyingGlass,
faPlus,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, TPamAccount, TPamFolder } from "@app/hooks/api/pam";
import { AccountView, AccountViewToggle } from "./AccountViewToggle";
import { PamAccessAccountModal } from "./PamAccessAccountModal";
import { PamAccountRow } from "./PamAccountRow";
import { PamAddAccountModal } from "./PamAddAccountModal";
import { PamAddFolderModal } from "./PamAddFolderModal";
import { PamDeleteAccountModal } from "./PamDeleteAccountModal";
import { PamDeleteFolderModal } from "./PamDeleteFolderModal";
import { PamFolderRow } from "./PamFolderRow";
import { PamUpdateAccountModal } from "./PamUpdateAccountModal";
import { PamUpdateFolderModal } from "./PamUpdateFolderModal";
import { useSubscription } from "@app/context";
enum OrderBy {
Name = "name"
}
type Filters = {
resource: string[];
};
type Props = {
accounts: TPamAccount[];
folders: TPamFolder[];
projectId: string;
};
export const PamAccountsTable = ({ accounts, folders, projectId }: Props) => {
const { subscription } = useSubscription();
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"misc",
"addFolder",
"updateFolder",
"deleteFolder",
"addAccount",
"accessAccount",
"updateAccount",
"deleteAccount"
] as const);
const {
search: initSearch,
accountPath,
accountView: initAccountView
} = useSearch({
from: ROUTE_PATHS.Pam.AccountsPage.id
});
const [accountView, setAccountView] = useState<AccountView>(initAccountView ?? AccountView.Flat);
const [filters, setFilters] = useState<Filters>({
resource: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
const { foldersByParentId, pathMap } = useMemo(() => {
const foldersById: Record<string, TPamFolder> = {};
const tempFoldersByParentId: Record<string, TPamFolder[]> = { null: [] };
const tempPathMap: Record<string, string> = { "/": "null" };
folders.forEach((folder) => {
foldersById[folder.id] = folder;
if (!tempFoldersByParentId[folder.parentId || "null"]) {
tempFoldersByParentId[folder.parentId || "null"] = [];
}
tempFoldersByParentId[folder.parentId || "null"].push(folder);
});
const buildPaths = (parentId: string | null, currentPath: string) => {
(tempFoldersByParentId[parentId || "null"] || []).forEach((folder) => {
const newPath = `${currentPath}${folder.name}/`;
tempPathMap[newPath] = folder.id;
buildPaths(folder.id, newPath);
});
};
buildPaths(null, "/");
return { foldersByParentId: tempFoldersByParentId, pathMap: tempPathMap };
}, [folders]);
const effectiveFolderIdForFiltering = useMemo(() => {
if (accountView === AccountView.Flat) {
return null;
}
const folderId = pathMap[accountPath];
return folderId === "null" ? null : folderId || null;
}, [accountView, accountPath, pathMap]);
const foldersToRender = useMemo(() => {
if (accountView === AccountView.Flat) {
return [];
}
return (foldersByParentId[effectiveFolderIdForFiltering || "null"] || []).filter((folder) =>
folder.name.toLowerCase().includes(search.trim().toLowerCase())
);
}, [accountView, effectiveFolderIdForFiltering, foldersByParentId, search]);
const accountsToProcess = useMemo(() => {
if (accountView === AccountView.Flat) {
return accounts;
}
return accounts.filter(
(acc) => (acc.folderId || "null") === (effectiveFolderIdForFiltering || "null")
);
}, [accountView, accounts, effectiveFolderIdForFiltering]);
const filteredAccounts = useMemo(
() =>
accountsToProcess
.filter((account) => {
const {
name,
description,
resource: { name: resourceName, id: resourceId }
} = account;
if (filters.resource.length && !filters.resource.includes(resourceId)) {
return false;
}
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue) ||
(description || "").toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [accOne, accTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return accOne.name.toLowerCase().localeCompare(accTwo.name.toLowerCase());
}
}),
[accountsToProcess, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredAccounts.length,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredAccounts.slice(offset, perPage * page),
[filteredAccounts, offset, perPage, page]
);
const handleSort = (column: OrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resource.length);
const handleFolderClick = (folder: TPamFolder) => {
if (accountView === AccountView.Flat) {
return;
}
const newPath = `${accountPath}${folder.name}/`;
navigate({ search: (prev) => ({ ...prev, accountPath: newPath }) });
};
const isContentEmpty = !filteredAccounts.length && !foldersToRender.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
const uniqueResources = useMemo(() => {
const resourceMap = new Map<string, TPamAccount["resource"]>();
accounts.forEach((account) => {
resourceMap.set(account.resource.id, account.resource);
});
return Array.from(resourceMap.values());
}, [accounts]);
return (
<div>
<div className="mt-4 flex gap-2">
<ProjectPermissionCan I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PamFolders}>
{(isAllowed) =>
isAllowed && (
<AccountViewToggle
value={accountView}
onChange={(e) => {
setAccountView(e);
navigate({
search: (prev) => ({
...prev,
accountView: e === AccountView.Flat ? undefined : e,
accountPath: e === AccountView.Flat ? "/" : prev.accountPath
})
});
}}
/>
)
}
</ProjectPermissionCan>
<Input
value={search}
onChange={(e) => {
const newSearch = e.target.value;
setSearch(newSearch);
navigate({
search: (prev) => ({ ...prev, search: newSearch || undefined }),
replace: true
});
}}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search accounts..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter accounts"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 min-w-10 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>Resource</DropdownMenuLabel>
{uniqueResources.length ? (
uniqueResources.map((resource) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
const newResources = filters.resource.includes(resource.id)
? filters.resource.filter((a) => a !== resource.id)
: [...filters.resource, resource.id];
setFilters((prev) => ({
...prev,
resource: newResources
}));
}}
key={resource.id}
icon={
filters.resource.includes(resource.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} resource`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{resource.name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Account Resources</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionPamAccountActions.Create}
a={ProjectPermissionSub.PamAccounts}
>
{(isAllowedToCreateAccounts) => (
<div className="flex">
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addAccount")}
isDisabled={!isAllowedToCreateAccounts || !subscription.pam}
className={`h-10 transition-colors ${accountView === AccountView.Flat ? "" : "rounded-r-none"}`}
>
Add Account
</Button>
{accountView !== AccountView.Flat && (
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
>
<DropdownMenuTrigger asChild>
<IconButton
variant="outline_bg"
ariaLabel="add-folder-or-import"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={5}>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.PamFolders}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} className="pr-2" />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add Folder
</Button>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</ProjectPermissionCan>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Accounts
<IconButton
variant="plain"
className={getClassName(OrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{accountView !== AccountView.Flat &&
foldersToRender.map((folder) => (
<PamFolderRow
key={folder.id}
folder={folder}
search={search}
onClick={() => handleFolderClick(folder)}
onUpdate={(e) => handlePopUpOpen("updateFolder", e)}
onDelete={(e) => handlePopUpOpen("deleteFolder", e)}
/>
))}
{currentPageData.map((account) => (
<PamAccountRow
key={account.id}
account={account}
search={search}
onAccess={(e) => {
handlePopUpOpen("accessAccount", e);
}}
onUpdate={(e) => handlePopUpOpen("updateAccount", e)}
onDelete={(e) => handlePopUpOpen("deleteAccount", e)}
/>
))}
</TBody>
</Table>
{Boolean(filteredAccounts.length) && (
<Pagination
count={filteredAccounts.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No accounts match search" : "No accounts"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
</TableContainer>
<PamDeleteFolderModal
isOpen={popUp.deleteFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
folder={popUp.deleteFolder.data}
/>
<PamUpdateFolderModal
isOpen={popUp.updateFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
folder={popUp.updateFolder.data}
/>
<PamAddFolderModal
isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
projectId={projectId}
currentFolderId={effectiveFolderIdForFiltering}
/>
<PamAccessAccountModal
isOpen={popUp.accessAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("accessAccount", isOpen)}
account={popUp.accessAccount.data}
/>
<PamDeleteAccountModal
isOpen={popUp.deleteAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteAccount", isOpen)}
account={popUp.deleteAccount.data}
/>
<PamUpdateAccountModal
isOpen={popUp.updateAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateAccount", isOpen)}
account={popUp.updateAccount.data}
/>
<PamAddAccountModal
isOpen={popUp.addAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addAccount", isOpen)}
projectId={projectId}
currentFolderId={effectiveFolderIdForFiltering}
/>
</div>
);
};
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { PamResourceType, TPamAccount } from "@app/hooks/api/pam";
import { PamAccountForm } from "./PamAccountForm/PamAccountForm";
import { ResourceSelect } from "./ResourceSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
projectId: string;
onComplete?: (account: TPamAccount) => void;
currentFolderId: string | null;
};
type ContentProps = {
onComplete: (account: TPamAccount) => void;
projectId: string;
currentFolderId: string | null;
};
const Content = ({ onComplete, projectId, currentFolderId }: ContentProps) => {
const [selectedResource, setSelectedResource] = useState<{
id: string;
name: string;
resourceType: PamResourceType;
} | null>(null);
if (selectedResource) {
return (
<PamAccountForm
onComplete={onComplete}
onBack={() => setSelectedResource(null)}
resourceId={selectedResource.id}
resourceName={selectedResource.name}
resourceType={selectedResource.resourceType}
projectId={projectId}
folderId={currentFolderId ?? undefined}
/>
);
}
return <ResourceSelect projectId={projectId} onSubmit={(e) => setSelectedResource(e.resource)} />;
};
export const PamAddAccountModal = ({
isOpen,
onOpenChange,
projectId,
onComplete,
currentFolderId
}: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Add Account"
subTitle="Select a resource to add an account under."
bodyClassName="overflow-visible"
>
<Content
projectId={projectId}
onComplete={(account) => {
if (onComplete) onComplete(account);
onOpenChange(false);
}}
currentFolderId={currentFolderId}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,48 @@
import { createNotification } from "@app/components/notifications";
import { Modal, ModalContent } from "@app/components/v2";
import { TPamFolder, useCreatePamFolder } from "@app/hooks/api/pam";
import { PamFolderForm } from "./PamFolderForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
projectId: string;
currentFolderId: string | null;
};
export const PamAddFolderModal = ({ isOpen, onOpenChange, projectId, currentFolderId }: Props) => {
const createPamFolder = useCreatePamFolder();
console.log({ currentFolderId });
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
try {
await createPamFolder.mutateAsync({
...formData,
parentId: currentFolderId,
projectId
});
createNotification({
text: "Successfully created folder",
type: "success"
});
onOpenChange(false);
} catch (err: any) {
console.error(err);
createNotification({
title: "Failed to create folder",
text: err.message,
type: "error"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent className="max-w-2xl" title="Create Folder">
<PamFolderForm onSubmit={onSubmit} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,55 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { TPamAccount, useDeletePamAccount } from "@app/hooks/api/pam";
type Props = {
account?: TPamAccount;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const PamDeleteAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
const deletePamAccount = useDeletePamAccount();
if (!account) return null;
const {
id: accountId,
name,
resourceId,
resource: { resourceType }
} = account;
const handleDelete = async () => {
try {
await deletePamAccount.mutateAsync({
accountId,
resourceId,
resourceType
});
createNotification({
text: "Successfully deleted account",
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete account",
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure you want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDelete}
/>
);
};
@@ -0,0 +1,48 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { TPamFolder, useDeletePamFolder } from "@app/hooks/api/pam";
type Props = {
folder?: TPamFolder;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const PamDeleteFolderModal = ({ isOpen, onOpenChange, folder }: Props) => {
const deletePamFolder = useDeletePamFolder();
if (!folder) return null;
const { id: folderId, name } = folder;
const handleDelete = async () => {
try {
await deletePamFolder.mutateAsync({
folderId
});
createNotification({
text: "Successfully deleted folder",
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete folder",
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure you want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDelete}
/>
);
};
@@ -0,0 +1,95 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, ModalClose, TextArea } from "@app/components/v2";
import { TPamFolder } from "@app/hooks/api/pam";
import { slugSchema } from "@app/lib/schemas";
type Props = {
folder?: TPamFolder;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = z.object({
name: slugSchema({ min: 1, max: 64, field: "Name" }),
description: z.string().max(512).optional()
});
type FormData = z.infer<typeof formSchema>;
export const PamFolderForm = ({ folder, onSubmit }: Props) => {
const isUpdate = Boolean(folder);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: folder
? {
name: folder.name,
description: folder.description || ""
}
: undefined
});
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty, errors }
} = form;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-folder" {...field} />
</FormControl>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<FormControl
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Description"
isOptional
>
<TextArea {...field} />
</FormControl>
)}
/>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Folder" : "Create Folder"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,99 @@
import { faEdit, faEllipsisV, faFolder, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { TPamFolder } from "@app/hooks/api/pam";
type Props = {
folder: TPamFolder;
onUpdate: (folder: TPamFolder) => void;
onDelete: (folder: TPamFolder) => void;
onClick: () => void;
search: string;
};
export const PamFolderRow = ({ folder, onClick, onDelete, onUpdate, search }: Props) => {
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={onClick}
>
<Td>
<div className="flex items-center gap-4">
<div className="flex w-5 justify-center">
<FontAwesomeIcon icon={faFolder} className="size-4 text-yellow-700" />
</div>
<span>
<HighlightText text={folder.name} highlight={search} />
</span>
</div>
</Td>
<Td>
<div className="flex h-[22px] justify-end">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="hidden w-6 group-hover:flex data-[state=open]:!flex"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.PamFolders}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={(e) => {
e.stopPropagation();
onUpdate(folder);
}}
>
Edit Folder
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.PamFolders}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
Delete Folder
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
);
};
@@ -0,0 +1,47 @@
import { components, OptionProps } from "react-select";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@app/components/v2";
import { PAM_RESOURCE_TYPE_MAP, PamResourceType } from "@app/hooks/api/pam";
export const PamResourceOption = ({
isSelected,
children,
...props
}: OptionProps<{ id: string; name: string; resourceType: PamResourceType }>) => {
const isCreateOption = props.data.id === "_create";
const { name, image } = PAM_RESOURCE_TYPE_MAP[props.data.resourceType];
return (
<components.Option isSelected={isSelected} {...props}>
<div className="flex flex-row items-center justify-between">
{isCreateOption ? (
<div className="flex items-center gap-x-1 text-mineshaft-400">
<FontAwesomeIcon icon={faPlus} size="sm" />
<span className="mr-auto">Create New Resource</span>
</div>
) : (
<>
<p className="truncate">{children}</p>
<div className="ml-2 mr-auto">
<Badge className="flex h-5 items-center gap-1 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<img
alt={`${name} logo`}
src={`/images/integrations/${image}`}
className="size-3"
/>
{name}
</Badge>
</div>
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</>
)}
</div>
</components.Option>
);
};
@@ -0,0 +1,26 @@
import { Modal, ModalContent } from "@app/components/v2";
import { TPamAccount } from "@app/hooks/api/pam";
import { PamAccountForm } from "./PamAccountForm/PamAccountForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
account?: TPamAccount;
};
export const PamUpdateAccountModal = ({ isOpen, onOpenChange, account }: Props) => {
if (!account) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent className="max-w-2xl" title="Edit Account" subTitle="Update account details.">
<PamAccountForm
onComplete={() => onOpenChange(false)}
account={account}
projectId={account.projectId}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,46 @@
import { createNotification } from "@app/components/notifications";
import { Modal, ModalContent } from "@app/components/v2";
import { TPamFolder, useUpdatePamFolder } from "@app/hooks/api/pam";
import { PamFolderForm } from "./PamFolderForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
folder?: TPamFolder;
};
export const PamUpdateFolderModal = ({ isOpen, onOpenChange, folder }: Props) => {
const updatePamFolder = useUpdatePamFolder();
if (!folder) return null;
const onSubmit = async (formData: Pick<TPamFolder, "name" | "description">) => {
try {
await updatePamFolder.mutateAsync({
...formData,
folderId: folder.id
});
createNotification({
text: "Successfully updated folder",
type: "success"
});
onOpenChange(false);
} catch (err: any) {
console.error(err);
createNotification({
title: "Failed to updated folder",
text: err.message,
type: "error"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent className="max-w-2xl" title="Edit Account" subTitle="Update account details.">
<PamFolderForm onSubmit={onSubmit} folder={folder} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,121 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { SingleValue } from "react-select";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { usePopUp } from "@app/hooks";
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
import { PamResourceOption } from "./PamResourceOption";
type Props = {
onSubmit: (data: FormData) => void;
projectId: string;
};
const formSchema = z.object({
resource: z.object({
id: z.string(),
name: z.string(),
resourceType: z.nativeEnum(PamResourceType)
})
});
type FormData = z.infer<typeof formSchema>;
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
const { permission } = useProjectPermission();
const { isPending, data: resources } = useListPamResources(projectId);
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
const form = useForm<FormData>({
resolver: zodResolver(formSchema)
});
const { handleSubmit, control, setValue } = form;
const canCreateResource = permission.can(
ProjectPermissionActions.Create,
ProjectPermissionSub.PamResources
);
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 (
<>
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
<FilterableSelect
value={value}
onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
handlePopUpOpen("addResource");
onChange(null);
return;
}
onChange(newValue);
}}
isLoading={isPending}
options={[
...(canCreateResource
? [
{
id: "_create",
name: "Create Resource",
// This is just to make typescript happy. Does not actually do anything
resourceType: PamResourceType.Postgres
}
]
: []),
...(resources ?? [])
]}
placeholder="Select resource..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
components={{ Option: PamResourceOption }}
/>
</FormControl>
)}
control={control}
name="resource"
/>
<div className="mt-6 flex items-center">
<Button className="mr-4" size="sm" type="submit" colorSchema="secondary">
Continue
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
<PamAddResourceModal
isOpen={popUp.addResource.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addResource", isOpen)}
projectId={projectId}
onComplete={(resource) => setValue("resource", resource)}
/>
</>
);
};
@@ -0,0 +1,49 @@
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { AccountView } from "./components/AccountViewToggle";
import { PamAccountsPage } from "./PamAccountsPage";
const PamAccountsPageQueryParamsSchema = z.object({
search: z.string().optional(),
accountView: z.nativeEnum(AccountView).optional(),
accountPath: z.string().catch("/")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/accounts"
)({
validateSearch: zodValidator(PamAccountsPageQueryParamsSchema),
search: {
middlewares: [stripSearchParams({ accountPath: "/" })]
},
beforeLoad: ({ context, params, search }) => {
const accountPathSegments = search.accountPath.split("/").filter(Boolean);
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Accounts",
link: linkOptions({
to: "/projects/pam/$projectId/accounts",
params: () => params as never,
search: (prev) => ({ ...prev, accountPath: "/" })
})
},
...accountPathSegments.map((segment, index) => {
const newPath = `/${accountPathSegments.slice(0, index + 1).join("/")}/`;
return {
label: segment,
link: linkOptions({
to: "/projects/pam/$projectId/accounts",
params: () => params as never,
search: (prev) => ({ ...prev, accountPath: newPath })
})
};
})
]
};
},
component: PamAccountsPage
});
@@ -0,0 +1,37 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamAccountActions } from "@app/context/ProjectPermissionContext/types";
import { PamResourcesSection } from "./components/PamResourcesSection";
export const PamResourcesPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PAM" })}</title>
</Helmet>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionPamAccountActions.Read}
a={ProjectPermissionSub.PamResources}
>
<div className="h-full bg-bunker-800">
<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="Resources"
description="Manage resources such as servers, databases, and more."
/>
<PamResourcesSection />
</div>
</div>
</div>
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,52 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { PamResourceType, TPamResource } from "@app/hooks/api/pam";
import { PamResourceForm } from "./PamResourceForm";
import { ResourceTypeSelect } from "./ResourceTypeSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
projectId: string;
onComplete?: (resource: TPamResource) => void;
};
type ContentProps = {
onComplete: (resource: TPamResource) => void;
projectId: string;
};
const Content = ({ onComplete, projectId }: ContentProps) => {
const [selectedResourceType, setSelectedResourceType] = useState<PamResourceType | null>(null);
if (selectedResourceType) {
return (
<PamResourceForm
onComplete={onComplete}
onBack={() => setSelectedResourceType(null)}
resourceType={selectedResourceType}
projectId={projectId}
/>
);
}
return <ResourceTypeSelect onSelect={setSelectedResourceType} />;
};
export const PamAddResourceModal = ({ isOpen, onOpenChange, projectId, onComplete }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent className="max-w-2xl" title="Add Resource" subTitle="Select a resource to add.">
<Content
projectId={projectId}
onComplete={(resource) => {
if (onComplete) onComplete(resource);
onOpenChange(false);
}}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,49 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { PAM_RESOURCE_TYPE_MAP, TPamResource, useDeletePamResource } from "@app/hooks/api/pam";
type Props = {
resource?: TPamResource;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const PamDeleteResourceModal = ({ isOpen, onOpenChange, resource }: Props) => {
const deletePamResource = useDeletePamResource();
if (!resource) return null;
const { id: resourceId, name, resourceType } = resource;
const handleDelete = async () => {
try {
await deletePamResource.mutateAsync({
resourceId,
resourceType
});
createNotification({
text: `Successfully removed ${PAM_RESOURCE_TYPE_MAP[resourceType].name} resource`,
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: `Failed to remove ${PAM_RESOURCE_TYPE_MAP[resourceType].name} resource`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure you want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDelete}
/>
);
};
@@ -0,0 +1,63 @@
import { Controller, useFormContext } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { gatewaysQueryKeys } from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
export const genericResourceFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 64, field: "Name" }),
gatewayId: z.string().min(1)
});
export const GenericResourceFields = () => {
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const {
formState: { errors },
control
} = useFormContext<{ name: string; gatewayId: string }>();
return (
<>
<Controller
name="name"
control={control}
render={({ field }) => (
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-resource" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="gatewayId"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message} label="Gateway">
<Select
value={value || (null as unknown as string)}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Select a Gateway..."
position="popper"
>
{(gateways || []).map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
);
};
@@ -0,0 +1,118 @@
import { createNotification } from "@app/components/notifications";
import {
PAM_RESOURCE_TYPE_MAP,
PamResourceType,
TPamResource,
useCreatePamResource,
useUpdatePamResource
} from "@app/hooks/api/pam";
import { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader";
import { PostgresResourceForm } from "./PostgresResourceForm";
type FormProps = {
onComplete: (resource: TPamResource) => void;
} & ({ resource: TPamResource } | { resourceType: PamResourceType });
type CreateFormProps = FormProps & {
resourceType: PamResourceType;
projectId: string;
};
type UpdateFormProps = FormProps & {
resource: TPamResource;
};
const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) => {
const createPamResource = useCreatePamResource();
const { name: resourceName } = PAM_RESOURCE_TYPE_MAP[resourceType];
const onSubmit = async (
formData: DiscriminativePick<
TPamResource,
"name" | "resourceType" | "connectionDetails" | "gatewayId"
>
) => {
try {
const resource = await createPamResource.mutateAsync({
...formData,
projectId
});
createNotification({
text: `Successfully created ${resourceName} resource`,
type: "success"
});
onComplete(resource);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to create ${resourceName} resource`,
text: err.message,
type: "error"
});
}
};
switch (resourceType) {
case PamResourceType.Postgres:
return <PostgresResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
};
const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
const updatePamResource = useUpdatePamResource();
const { name: resourceName } = PAM_RESOURCE_TYPE_MAP[resource.resourceType];
const onSubmit = async (
formData: DiscriminativePick<TPamResource, "name" | "resourceType" | "connectionDetails">
) => {
try {
const updatedResource = await updatePamResource.mutateAsync({
resourceId: resource.id,
...formData
});
createNotification({
text: `Successfully updated ${resourceName} resource`,
type: "success"
});
onComplete(updatedResource);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${resourceName} resource`,
text: err.message,
type: "error"
});
}
};
switch (resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resource.resourceType}`);
}
};
type Props = { onBack?: () => void; projectId: string } & Pick<FormProps, "onComplete"> &
(
| { resourceType: PamResourceType; resource?: undefined }
| { resourceType?: undefined; resource: TPamResource }
);
export const PamResourceForm = ({ onBack, projectId, ...props }: Props) => {
const { resource, resourceType } = props;
return (
<div>
<PamResourceHeader resourceType={resourceType || resource.resourceType} onBack={onBack} />
{resource ? (
<UpdateForm {...props} resource={resource} />
) : (
<CreateForm {...props} resourceType={resourceType} projectId={projectId} />
)}
</div>
);
};
@@ -0,0 +1,82 @@
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam";
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
import { SqlResourceFields } from "./shared/SqlResourceFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = {
resource?: TPostgresResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.Postgres),
connectionDetails: BaseSqlResourceSchema
});
type FormData = z.infer<typeof formSchema>;
export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.Postgres,
connectionDetails: {
host: "",
port: 5432,
database: "default",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(onSubmit)(e);
}}
>
<GenericResourceFields />
<SqlResourceFields
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Details" : "Create Resource"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,2 @@
export * from "./GenericResourceFields";
export * from "./PamResourceForm";
@@ -0,0 +1,159 @@
import { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react";
import { FormControl, Input, Switch, TextArea, Tooltip } from "@app/components/v2";
type Props = {
selectedTabIndex: number;
setSelectedTabIndex: Dispatch<SetStateAction<number>>;
};
export const SqlResourceFields = ({ setSelectedTabIndex, selectedTabIndex }: Props) => {
const { control, watch } = useFormContext();
const sslEnabled = watch("connectionDetails.sslEnabled");
return (
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
}`
}
>
Configuration
</Tab>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
}`
}
>
SSL ({sslEnabled ? "Enabled" : "Disabled"})
</Tab>
</Tab.List>
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
<Tab.Panel>
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="connectionDetails.host"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.database"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Database Name"
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} />
</FormControl>
)}
/>
</div>
</Tab.Panel>
<Tab.Panel>
<Controller
name="connectionDetails.sslEnabled"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Enable SSL
</Switch>
</FormControl>
)}
/>
<Controller
name="connectionDetails.sslCertificate"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={sslEnabled ? "" : "opacity-50"}
label="SSL Certificate"
isOptional
>
<TextArea className="h-[3.5rem] !resize-none" {...field} isDisabled={!sslEnabled} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.sslRejectUnauthorized"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className={sslEnabled ? "" : "opacity-50"}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-reject-unauthorized"
thumbClassName="bg-mineshaft-800"
isChecked={sslEnabled ? value : false}
onCheckedChange={onChange}
isDisabled={!sslEnabled}
>
<p className="w-[9.5rem]">
Reject Unauthorized
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will only connect to the server if it has a valid,
trusted SSL certificate.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
};
@@ -0,0 +1,14 @@
import { z } from "zod";
export const BaseSqlResourceSchema = z.object({
host: z.string().trim().min(1, "Host required"),
port: z.coerce.number().default(5432),
database: z.string().trim().min(1, "Database required").default("default"),
sslEnabled: z.boolean().default(true),
sslRejectUnauthorized: z.boolean().default(true),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
});
@@ -0,0 +1,33 @@
import { PAM_RESOURCE_TYPE_MAP, PamResourceType } from "@app/hooks/api/pam";
type Props = {
resourceType: PamResourceType;
onBack?: () => void;
};
export const PamResourceHeader = ({ resourceType, onBack }: Props) => {
const details = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
<img
alt={`${details.name} logo`}
src={`/images/integrations/${details.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">{details.name}</div>
<p className="text-sm leading-4 text-mineshaft-400">External resource</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 resource type
</button>
)}
</div>
);
};
@@ -0,0 +1,98 @@
import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { PAM_RESOURCE_TYPE_MAP, TPamResource } from "@app/hooks/api/pam";
type Props = {
resource: TPamResource;
onUpdate: (resource: TPamResource) => void;
onDelete: (resource: TPamResource) => void;
search: string;
};
export const PamResourceRow = ({ resource, onUpdate, onDelete, search }: Props) => {
const { name, resourceType } = resource;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
<Tr className={twMerge("group h-10")}>
<Td>
<div className="flex items-center gap-4">
<div className="relative">
<img alt={resourceTypeName} src={`/images/integrations/${image}`} className="size-5" />
</div>
<span>
<HighlightText text={name} highlight={search} />
</span>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<span>
<HighlightText text={resourceTypeName} highlight={search} />
</span>
</Badge>
</div>
</Td>
<Td>
<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">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.PamResources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onUpdate(resource)}
>
Edit Resource
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.PamResources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(resource)}
>
Delete Resource
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,17 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamResources } from "@app/hooks/api/pam";
import { PamResourcesTable } from "./PamResourcesTable";
export const PamResourcesSection = () => {
const { currentProject } = useProject();
const { data: resources = [], isPending } = useListPamResources(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return <PamResourcesTable resources={resources} projectId={currentProject.id} />;
};
@@ -0,0 +1,326 @@
import { useMemo, useState } from "react";
import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faPlus,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, PamResourceType, TPamResource } from "@app/hooks/api/pam";
import { PamAddResourceModal } from "./PamAddResourceModal";
import { PamDeleteResourceModal } from "./PamDeleteResourceModal";
import { PamResourceRow } from "./PamResourceRow";
import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
enum OrderBy {
Name = "name"
}
type Filters = {
resourceType: PamResourceType[];
};
type Props = {
projectId: string;
resources: TPamResource[];
};
export const PamResourcesTable = ({ projectId, resources }: Props) => {
const { subscription } = useSubscription();
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path });
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"updateResource",
"addResource",
"deleteResource"
] as const);
const { search: initSearch } = useSearch({
from: ROUTE_PATHS.Pam.ResourcesPage.id
});
const [filters, setFilters] = useState<Filters>({
resourceType: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
const filteredResources = useMemo(
() =>
resources
.filter((resource) => {
const { name, resourceType } = resource;
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) {
return false;
}
const searchValue = search.trim().toLowerCase();
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
name.toLowerCase().includes(searchValue) ||
resourceTypeName.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return one.name.toLowerCase().localeCompare(two.name.toLowerCase());
}
}),
[resources, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredResources.length,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredResources.slice(offset, perPage * page),
[filteredResources, offset, perPage, page]
);
const handleSort = (column: OrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceType.length);
const isContentEmpty = !filteredResources.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => {
const newSearch = e.target.value;
setSearch(newSearch);
navigate({
search: (prev) => ({ ...prev, search: newSearch || undefined }),
replace: true
});
}}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search resources..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter resources"
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>Resource Type</DropdownMenuLabel>
{resources.length ? (
[...new Set(resources.map(({ resourceType }) => resourceType))].map((type) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
resourceType: prev.resourceType.includes(type)
? prev.resourceType.filter((a) => a !== type)
: [...prev.resourceType, type]
}));
}}
key={type}
icon={
filters.resourceType.includes(type) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} resource type`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Resources</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isGatewayAllowed) => (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.PamResources}
>
{(isAllowed) => (
<Tooltip
isDisabled={isGatewayAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addResource")}
isDisabled={
!isAllowed || !isGatewayAllowed || !subscription.gateway || !subscription.pam
}
>
Add Resource
</Button>
</Tooltip>
)}
</ProjectPermissionCan>
)}
</OrgPermissionCan>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Resource
<IconButton
variant="plain"
className={getClassName(OrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{currentPageData.map((resource) => (
<PamResourceRow
key={resource.id}
resource={resource}
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
search={search.trim().toLowerCase()}
/>
))}
</TBody>
</Table>
{Boolean(filteredResources.length) && (
<Pagination
count={filteredResources.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No resources match search" : "No resources"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
</TableContainer>
<PamDeleteResourceModal
isOpen={popUp.deleteResource.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteResource", isOpen)}
resource={popUp.deleteResource.data}
/>
<PamUpdateResourceModal
isOpen={popUp.updateResource.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateResource", isOpen)}
resource={popUp.updateResource.data}
/>
<PamAddResourceModal
isOpen={popUp.addResource.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addResource", isOpen)}
projectId={projectId}
/>
</div>
);
};
@@ -0,0 +1,32 @@
import { Modal, ModalContent } from "@app/components/v2";
import { PAM_RESOURCE_TYPE_MAP, TPamResource } from "@app/hooks/api/pam";
import { PamResourceForm } from "./PamResourceForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
resource?: TPamResource;
};
export const PamUpdateResourceModal = ({ isOpen, onOpenChange, resource }: Props) => {
if (!resource) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Resource"
subTitle={`Update details for this ${
PAM_RESOURCE_TYPE_MAP[resource.resourceType].name
} resource.`}
>
<PamResourceForm
onComplete={() => onOpenChange(false)}
resource={resource}
projectId={resource.projectId}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,141 @@
import { useMemo } from "react";
import { faInfoCircle, faMagnifyingGlass, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EmptyState, Input, Pagination, Spinner, Tooltip } from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import {
PAM_RESOURCE_TYPE_MAP,
PamResourceType,
useListPamResourceOptions
} from "@app/hooks/api/pam";
type Props = {
onSelect: (resource: PamResourceType) => void;
};
export const ResourceTypeSelect = ({ onSelect }: Props) => {
const { isPending, data: resourceOptions } = useListPamResourceOptions();
const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", {
initPerPage: 16
});
const filteredOptions = useMemo(
() =>
resourceOptions?.filter(
({ name, resource }) =>
name.toLowerCase().includes(search.trim().toLowerCase()) ||
resource.toLowerCase().includes(search.trim().toLowerCase())
) ?? [],
[resourceOptions, 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, name, size = 50 } = PAM_RESOURCE_TYPE_MAP[option.resource];
return (
<button
type="button"
onClick={() => onSelect(option.resource)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<div className="relative">
<img
src={`/images/integrations/${image}`}
style={{
width: `${size}px`
}}
className="mt-auto"
alt={`${name} logo`}
/>
</div>
<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 resources 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 resources.</p>
<p>
{"If you don't see the resource 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 resource 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,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { PamResourcesPage } from "./PamResourcesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/resources"
)({
validateSearch: zodValidator(
z.object({
search: z.string().optional()
})
),
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Resources"
}
]
};
},
component: PamResourcesPage
});
@@ -0,0 +1,59 @@
import { Helmet } from "react-helmet";
import { useParams } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamSessionActions } from "@app/context/ProjectPermissionContext/types";
import { useGetPamSessionById } from "@app/hooks/api/pam";
import { PamSessionDetailsSection } from "./components/PamSessionDetailsSection";
import { PamSessionLogsSection } from "./components/PamSessionLogsSection";
const Page = () => {
const sessionId = useParams({
from: ROUTE_PATHS.Pam.PamSessionByIDPage.id,
select: (el) => el.sessionId
});
const { data: session } = useGetPamSessionById(sessionId);
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{session && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title={`${session.accountName} Session`}
description={`View details for this ${session.accountName} session.`}
/>
<div className="flex">
<div className="mr-4 w-96">
<PamSessionDetailsSection session={session} />
</div>
<div className="w-full">
<PamSessionLogsSection session={session} />
</div>
</div>
</div>
)}
</div>
);
};
export const PamSessionByIDPage = () => {
return (
<>
<Helmet>
<title>PAM Session</title>
</Helmet>
<ProjectPermissionCan
I={ProjectPermissionPamSessionActions.Read}
a={ProjectPermissionSub.PamSessions}
passThrough={false}
renderGuardBanner
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,125 @@
import { faBoxOpen, faCheck, faCopy, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, IconButton, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
import { PamSessionStatusBadge } from "../../PamSessionsPage/components/PamSessionStatusBadge";
type Props = {
session: TPamSession;
};
const DetailItem = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="mb-4">
<p className="font-semibold">{label}</p>
{children}
</div>
);
export const PamSessionDetailsSection = ({
session: {
id,
accountName,
resourceType,
resourceName,
status,
actorName,
actorEmail,
createdAt,
endedAt,
actorIp,
actorUserAgent,
startedAt,
expiresAt
}
}: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const details = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
<div className="max-w-[350px] rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Session Details</h3>
</div>
<div className="pt-4 text-sm text-mineshaft-300">
<DetailItem label="Session ID">
<div className="group flex align-top">
<p className="truncate">{id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</DetailItem>
<DetailItem label="Account">
<div className="flex items-center gap-2">
<img
alt={`${details.name} logo`}
src={`/images/integrations/${details.image}`}
className="size-4"
/>
<p>{accountName}</p>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-yellow/20 text-yellow">
<FontAwesomeIcon icon={faBoxOpen} />
{resourceName}
</Badge>
</div>
</DetailItem>
<DetailItem label="Actor">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faUser} className="-translate-y-px" />
<p>
<strong>{actorName}</strong> ({actorEmail})
</p>
</div>
</DetailItem>
<DetailItem label="Status">
<PamSessionStatusBadge status={status} />
</DetailItem>
<DetailItem label="IP Address">
<p>{actorIp}</p>
</DetailItem>
<DetailItem label="User Agent">
<p className="truncate">{actorUserAgent}</p>
</DetailItem>
<DetailItem label="Created At">
<p>{new Date(createdAt).toLocaleString()}</p>
</DetailItem>
<DetailItem label="Expires At">
<p>{expiresAt ? new Date(expiresAt).toLocaleString() : "Never"}</p>
</DetailItem>
<DetailItem label="Started At">
<p>{startedAt ? new Date(startedAt).toLocaleString() : "Not Started"}</p>
</DetailItem>
<DetailItem label="Ended At">
<p>{endedAt ? new Date(endedAt).toLocaleString() : "Ongoing"}</p>
</DetailItem>
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { faTerminal } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TPamSession } from "@app/hooks/api/pam";
type Props = {
session: TPamSession;
};
export const PamSessionLogsSection = ({ session }: Props) => {
return (
<div className="flex h-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Session Logs</h3>
</div>
<div className="flex grow flex-col gap-4 text-xs">
{session.commandLogs.length > 0 ? (
session.commandLogs.map((log) => (
<div key={log.timestamp} className="flex flex-col">
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="whitespace-pre-wrap font-mono">{log.input}</div>
<div className="whitespace-pre-wrap font-mono text-bunker-300">{log.output}</div>
</div>
))
) : (
<div className="flex w-full grow items-center justify-center text-bunker-300">
No session logs
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,26 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { PamSessionByIDPage } from "./PamSessionByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/$sessionId"
)({
component: PamSessionByIDPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Sessions",
link: linkOptions({
to: "/projects/pam/$projectId/sessions",
params
})
},
{
label: "Details"
}
]
};
}
});
@@ -0,0 +1,37 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamSessionActions } from "@app/context/ProjectPermissionContext/types";
import { PamSessionSection } from "./components/PamSessionSection";
export const PamSessionPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PAM" })}</title>
</Helmet>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionPamSessionActions.Read}
a={ProjectPermissionSub.PamSessions}
>
<div className="h-full bg-bunker-800">
<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="Sessions"
description="Filter and search through account sessions."
/>
<PamSessionSection />
</div>
</div>
</div>
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,198 @@
import { useState } from "react";
import {
faBoxOpen,
faChevronDown,
faChevronUp,
faEdit,
faEllipsisV,
faTerminal
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
type Props = {
session: TPamSession;
search: string;
filteredCommandLogs: TPamSession["commandLogs"];
};
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
const router = useRouter();
const [showAllLogs, setShowAllLogs] = useState(false);
const {
id,
accountName,
resourceType,
resourceName,
projectId,
status,
actorName,
actorEmail,
createdAt,
endedAt
} = session;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
const LOGS_TO_SHOW = 5;
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
return (
<>
<Tr
className={twMerge("group h-10 cursor-pointer hover:bg-bunker-400/20")}
onClick={() => router.history.push(`/projects/pam/${projectId}/sessions/${id}`)}
>
<Td>
<div className="flex items-center gap-4">
<div className="relative">
<img
alt={resourceTypeName}
src={`/images/integrations/${image}`}
className="size-6"
/>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col">
<span>
<HighlightText text={accountName} highlight={search} />
</span>
<div className="flex items-center gap-1 text-xs text-bunker-300">
<FontAwesomeIcon icon={faBoxOpen} className="size-3" />
<span>
<HighlightText text={resourceName} highlight={search} />
</span>
</div>
</div>
</div>
</div>
</Td>
<Td>
<div className="flex flex-col">
<span>
<HighlightText text={actorName} highlight={search} />
</span>
<span className="text-xs text-bunker-300">
<HighlightText text={actorEmail} highlight={search} />
</span>
</div>
</Td>
<Td>
<div className="flex flex-col">
<span>{new Date(createdAt).toLocaleTimeString()}</span>
<span className="text-xs text-bunker-300">
{new Date(createdAt).toLocaleDateString()}
</span>
</div>
</Td>
<Td>
{endedAt ? (
<div className="flex flex-col">
<span>{new Date(endedAt).toLocaleTimeString()}</span>
<span className="text-xs text-bunker-300">
{new Date(endedAt).toLocaleDateString()}
</span>
</div>
) : (
<span className="text-bunker-400">Ongoing</span>
)}
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<PamSessionStatusBadge status={status} />
<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">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.PamResources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() =>
router.history.push(`/projects/pam/${projectId}/sessions/${id}`)
}
>
View Session
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
{filteredCommandLogs.length > 0 && (
<Tr>
<Td colSpan={5} className="py-3 text-xs">
{logsToShow.map((log) => (
<div key={`${id}-log-${log.timestamp}`} className="mb-4 flex flex-col last:mb-0">
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="font-mono">
<HighlightText text={log.input} highlight={search} />
</div>
<div className="font-mono text-bunker-300">
<HighlightText text={log.output} highlight={search} />
</div>
</div>
))}
{filteredCommandLogs.length > LOGS_TO_SHOW && (
<div className="mt-2">
<Button
variant="link"
size="xs"
leftIcon={<FontAwesomeIcon icon={showAllLogs ? faChevronUp : faChevronDown} />}
onClick={() => setShowAllLogs(!showAllLogs)}
className="p-0 text-mineshaft-300 hover:text-primary"
>
{showAllLogs
? "Show less"
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
</Button>
</div>
)}
</Td>
</Tr>
)}
</>
);
};
@@ -0,0 +1,17 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamSessions } from "@app/hooks/api/pam";
import { PamSessionsTable } from "./PamSessionsTable";
export const PamSessionSection = () => {
const { currentProject } = useProject();
const { data: sessions = [], isPending } = useListPamSessions(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return <PamSessionsTable sessions={sessions} />;
};
@@ -0,0 +1,63 @@
import {
faBan,
faCircle,
faGavel,
faHourglass,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Badge } from "@app/components/v2";
import { PamSessionStatus } from "@app/hooks/api/pam";
interface StatusConfig {
bgColor: string;
textColor: string;
icon: IconDefinition;
iconClassName?: string;
}
const PAM_SESSION_STATUS_CONFIG: Record<PamSessionStatus, StatusConfig> = {
[PamSessionStatus.Active]: {
bgColor: "bg-green/20",
textColor: "text-green",
icon: faCircle,
iconClassName: "size-2 animate-pulse"
},
[PamSessionStatus.Terminated]: {
bgColor: "bg-red/20",
textColor: "text-red",
icon: faGavel
},
[PamSessionStatus.Starting]: {
bgColor: "bg-yellow/20",
textColor: "text-yellow",
icon: faHourglass,
iconClassName: "animate-spin"
},
[PamSessionStatus.Ended]: {
bgColor: "bg-bunker-300/20",
textColor: "text-bunker-300",
icon: faBan
}
};
export const PamSessionStatusBadge = ({ status }: { status: PamSessionStatus }) => {
const config = PAM_SESSION_STATUS_CONFIG[status];
const displayName = status[0].toUpperCase() + status.slice(1);
return (
<Badge
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap",
config.bgColor,
config.textColor
)}
>
<FontAwesomeIcon icon={config.icon} className={config.iconClassName} />
{displayName}
</Badge>
);
};
@@ -0,0 +1,411 @@
import { useEffect, useMemo, useState } from "react";
import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
PAM_RESOURCE_TYPE_MAP,
PamResourceType,
PamSessionStatus,
TPamSession
} from "@app/hooks/api/pam";
import { PamSessionRow } from "./PamSessionRow";
enum OrderBy {
Account = "account",
Actor = "actor",
CreatedAt = "createdAt",
EndedAt = "endedAt"
}
type Filters = {
resourceType: PamResourceType[];
status: PamSessionStatus[];
};
type Props = {
sessions: TPamSession[];
};
export const PamSessionsTable = ({ sessions }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.SessionsPage.path });
const { search: initSearch } = useSearch({
from: ROUTE_PATHS.Pam.SessionsPage.id
});
const [filters, setFilters] = useState<Filters>({
resourceType: [],
status: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.CreatedAt, {
initPerPage: 20,
initSearch
});
useEffect(() => {
setOrderDirection(OrderByDirection.DESC);
}, []);
const sessionsWithMatches = useMemo(() => {
const searchValue = search.trim().toLowerCase();
if (!searchValue) {
return sessions.map((session) => ({
session,
isMatch: true,
filteredLogs: []
}));
}
return sessions.map((session) => {
const {
resourceType,
accountName,
actorEmail,
actorIp,
actorName,
actorUserAgent,
id,
resourceName,
userId,
commandLogs
} = session;
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
const isMetaMatch =
resourceTypeName.toLowerCase().includes(searchValue) ||
accountName.toLowerCase().includes(searchValue) ||
actorEmail.toLowerCase().includes(searchValue) ||
actorIp.toLowerCase().includes(searchValue) ||
actorName.toLowerCase().includes(searchValue) ||
actorUserAgent.toLowerCase().includes(searchValue) ||
id.toLowerCase().includes(searchValue) ||
(userId ?? "").toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue);
const filteredLogs =
searchValue.length >= 2
? commandLogs.filter(
(log) =>
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
)
: [];
return {
session,
isMatch: isMetaMatch || filteredLogs.length > 0,
filteredLogs
};
});
}, [sessions, search]);
const filteredSessions = useMemo(
() =>
sessionsWithMatches
.filter((item) => {
if (!item.isMatch) return false;
const { resourceType, status } = item.session;
if (
(filters.resourceType.length && !filters.resourceType.includes(resourceType)) ||
(filters.status.length && !filters.status.includes(status))
) {
return false;
}
return true;
})
.sort((a, b) => {
const [one, two] =
orderDirection === OrderByDirection.ASC
? [a.session, b.session]
: [b.session, a.session];
switch (orderBy) {
case OrderBy.Account:
return one.accountName.toLowerCase().localeCompare(two.accountName.toLowerCase());
case OrderBy.Actor:
return one.actorName.toLowerCase().localeCompare(two.actorName.toLowerCase());
case OrderBy.EndedAt: {
const dateOne = one.endedAt || one.createdAt;
const dateTwo = two.endedAt || two.createdAt;
return new Date(dateOne).getTime() - new Date(dateTwo).getTime();
}
case OrderBy.CreatedAt:
default: {
const dateOne = one.createdAt;
const dateTwo = two.createdAt;
return new Date(dateOne).getTime() - new Date(dateTwo).getTime();
}
}
}),
[sessionsWithMatches, orderDirection, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredSessions.length,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredSessions.slice(offset, perPage * page),
[filteredSessions, offset, perPage, page]
);
const handleSort = (column: OrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceType.length || filters.status.length);
const isContentEmpty = !filteredSessions.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => {
const newSearch = e.target.value;
setSearch(newSearch);
navigate({
search: (prev) => ({ ...prev, search: newSearch || undefined }),
replace: true
});
}}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search sessions and logs..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter sessions"
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>Session Status</DropdownMenuLabel>
{sessions.length ? (
[...new Set(sessions.map(({ status }) => status))].map((status) => {
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((a) => a !== status)
: [...prev.status, status]
}));
}}
key={status}
icon={
filters.status.includes(status) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
{status[0].toUpperCase() + status.slice(1)}
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Sessions</DropdownMenuItem>
)}
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
{sessions.length ? (
[...new Set(sessions.map(({ resourceType }) => resourceType))].map((type) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
resourceType: prev.resourceType.includes(type)
? prev.resourceType.filter((a) => a !== type)
: [...prev.resourceType, type]
}));
}}
key={type}
icon={
filters.resourceType.includes(type) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} resource type`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Sessions</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Account
<IconButton
variant="plain"
className={getClassName(OrderBy.Account)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Account)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Account)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Actor
<IconButton
variant="plain"
className={getClassName(OrderBy.Actor)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Actor)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Actor)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Created At
<IconButton
variant="plain"
className={getClassName(OrderBy.CreatedAt)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.CreatedAt)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.CreatedAt)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Ended At
<IconButton
variant="plain"
className={getClassName(OrderBy.EndedAt)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.EndedAt)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.EndedAt)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{currentPageData.map(({ session, filteredLogs }) => (
<PamSessionRow
key={session.id}
session={session}
search={search.trim().toLowerCase()}
filteredCommandLogs={filteredLogs}
/>
))}
</TBody>
</Table>
{Boolean(filteredSessions.length) && (
<Pagination
count={filteredSessions.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No sessions match search" : "No sessions"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { PamSessionPage } from "./PamSessionsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/"
)({
validateSearch: zodValidator(
z.object({
search: z.string().optional()
})
),
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Sessions"
}
]
};
},
component: PamSessionPage
});
@@ -0,0 +1,28 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab";
export const SettingsPage = () => {
const { t } = useTranslation();
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader title="Settings" description="Configure your PAM project." />
<Tabs defaultValue="tab-project-general">
<TabList>
<Tab value="tab-project-general">General</Tab>
</TabList>
<TabPanel value="tab-project-general">
<ProjectGeneralTab />
</TabPanel>
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "./SettingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/settings"
)({
component: SettingsPage,
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Settings"
}
]
};
}
});
+37
View File
@@ -0,0 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
import { BreadcrumbTypes } from "@app/components/v2";
import { projectKeys } from "@app/hooks/api";
import { fetchProjectById } from "@app/hooks/api/projects/queries";
import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
import { PamLayout } from "@app/layouts/PamLayout";
import { ProjectSelect } from "@app/layouts/ProjectLayout/components/ProjectSelect";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout"
)({
component: PamLayout,
beforeLoad: async ({ params, context }) => {
const project = await context.queryClient.ensureQueryData({
queryKey: projectKeys.getProjectById(params.projectId),
queryFn: () => fetchProjectById(params.projectId)
});
await context.queryClient.ensureQueryData({
queryKey: roleQueryKeys.getUserProjectPermissions({
projectId: params.projectId
}),
queryFn: () => fetchUserProjectPermissions({ projectId: params.projectId })
});
return {
project,
breadcrumbs: [
{
type: BreadcrumbTypes.Component,
component: ProjectSelect
}
]
};
}
});