mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 16:28:12 +00:00
feat(pam): PAM Platform V1
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
+49
@@ -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>
|
||||
);
|
||||
};
|
||||
+73
@@ -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>
|
||||
);
|
||||
};
|
||||
+55
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user