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,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)}
/>
</>
);
};