mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 05:00:56 +00:00
feat(pam): PAM Platform V1
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+63
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+118
@@ -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>
|
||||
);
|
||||
};
|
||||
+82
@@ -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";
|
||||
+159
@@ -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>
|
||||
);
|
||||
};
|
||||
+14
@@ -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't see the resource you're looking for?</span>
|
||||
<FontAwesomeIcon size="xs" icon={faInfoCircle} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
count={filteredOptions.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
perPageList={[16]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user