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