feature: secret sync base + aws parameter store & github

This commit is contained in:
Scott Wilson
2025-01-15 21:52:50 -08:00
parent b440e918ac
commit 473efa91f0
214 changed files with 10212 additions and 623 deletions
@@ -0,0 +1,69 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { CreateSecretSyncForm } from "./forms";
import { SecretSyncModalHeader } from "./SecretSyncModalHeader";
import { SecretSyncSelect } from "./SecretSyncSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onComplete: (secretSync: TSecretSync) => void;
selectedSync: SecretSync | null;
setSelectedSync: (selectedSync: SecretSync | null) => void;
};
const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => {
if (selectedSync) {
return (
<CreateSecretSyncForm
onComplete={onComplete}
onCancel={() => setSelectedSync(null)}
destination={selectedSync}
/>
);
}
return <SecretSyncSelect onSelect={setSelectedSync} />;
};
export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => {
const [selectedSync, setSelectedSync] = useState<SecretSync | null>(null);
return (
<Modal
{...props}
onOpenChange={(isOpen) => {
if (!isOpen) setSelectedSync(null);
onOpenChange(isOpen);
}}
>
<ModalContent
title={
selectedSync ? (
<SecretSyncModalHeader isConfigured={false} destination={selectedSync} />
) : (
"Add Sync"
)
}
className="max-w-2xl"
subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."}
bodyClassName="overflow-visible"
>
<Content
onComplete={() => {
setSelectedSync(null);
onOpenChange(false);
}}
selectedSync={selectedSync}
setSelectedSync={setSelectedSync}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,55 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useDeleteSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync?: TSecretSync;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: () => void;
};
export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComplete }: Props) => {
const deleteSync = useDeleteSecretSync();
if (!secretSync) return null;
const { id: syncId, name, destination } = secretSync;
const handleDeleteSecretSync = async () => {
const destinationName = SECRET_SYNC_MAP[destination].name;
try {
await deleteSync.mutateAsync({
syncId,
destination
});
createNotification({
text: `Successfully removed ${destinationName} Sync`,
type: "success"
});
if (onComplete) onComplete();
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: `Failed remove ${destinationName} Sync`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDeleteSecretSync}
/>
);
};
@@ -0,0 +1,33 @@
import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
import { Modal, ModalContent } from "@app/components/v2";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
import { EditSecretSyncForm } from "./forms";
import { SecretSyncModalHeader } from "./SecretSyncModalHeader";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
secretSync?: TSecretSync;
fields: SecretSyncEditFields;
};
export const EditSecretSyncModal = ({ secretSync, onOpenChange, fields, ...props }: Props) => {
if (!secretSync) return null;
return (
<Modal {...props} onOpenChange={onOpenChange}>
<ModalContent
title={<SecretSyncModalHeader isConfigured destination={secretSync.destination} />}
className="max-w-2xl"
bodyClassName="overflow-visible"
>
<EditSecretSyncForm
onComplete={() => onOpenChange(false)}
fields={fields}
secretSync={secretSync}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,167 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SECRET_SYNC_IMPORT_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import {
SecretSyncImportBehavior,
TSecretSync,
useTriggerSecretSyncImportSecrets
} from "@app/hooks/api/secretSyncs";
type Props = {
secretSync?: TSecretSync;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretSync: TSecretSync;
onComplete: () => void;
};
const FormSchema = z.object({
importBehavior: z.nativeEnum(SecretSyncImportBehavior)
});
type TFormData = z.infer<typeof FormSchema>;
const Content = ({ secretSync, onComplete }: ContentProps) => {
const { id: syncId, destination } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = useForm<TFormData>({ resolver: zodResolver(FormSchema) });
const triggerImportSecrets = useTriggerSecretSyncImportSecrets();
const handleTriggerImportSecrets = async ({ importBehavior }: TFormData) => {
try {
await triggerImportSecrets.mutateAsync({
syncId,
destination,
importBehavior
});
createNotification({
text: `Successfully triggered secret import for ${destinationName} Sync`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to trigger secret import for ${destinationName} Sync`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(handleTriggerImportSecrets)}>
<p className="mb-8 text-sm text-mineshaft-200">
Are you sure you want to import secrets from this {destinationName} destination into
Infiscal?
</p>
<Controller
name="importBehavior"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipClassName="max-w-lg py-3"
tooltipText={
<div className="flex flex-col gap-3">
<p>
Specify how Infisical should resolve the initial sync to {destinationName}. The
following options are available:
</p>
<ul className="flex list-disc flex-col gap-3 pl-4">
{Object.values(SECRET_SYNC_IMPORT_BEHAVIOR_MAP).map((details) => {
const { name, description } = details(destinationName);
return (
<li key={name}>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">{name}</span>: {description}
</p>
</li>
);
})}
</ul>
</div>
}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Initial Sync Behavior"
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
placeholder="Select an option..."
dropdownContainerClassName="max-w-none"
>
{Object.entries(SECRET_SYNC_IMPORT_BEHAVIOR_MAP).map(([key, details]) => {
const { name } = details(destinationName);
return (
<SelectItem value={key} key={key}>
{name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex w-full items-center justify-between gap-2">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
type="submit"
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
colorSchema="secondary"
>
Import Secrets
</Button>
</div>
</form>
);
};
export const SecretSyncImportSecretsModal = ({ isOpen, onOpenChange, secretSync }: Props) => {
if (!secretSync) return null;
const destinationName = SECRET_SYNC_MAP[secretSync.destination].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Import Secrets"
subTitle={`Import secrets into Infisical from this ${destinationName} Sync destination.`}
>
<Content secretSync={secretSync} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,112 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
import {
faCheck,
faDownload,
faTriangleExclamation,
faXmark,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { differenceInSeconds } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Badge, Tooltip } from "@app/components/v2";
import { BadgeProps } from "@app/components/v2/Badge/Badge";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
className?: string;
mini?: boolean;
};
export const SecretSyncImportStatusBadge = ({ secretSync, className, mini }: Props) => {
const { importStatus, lastImportMessage, lastImportedAt, destination } = secretSync;
const [hide, setHide] = useState(importStatus === SecretSyncStatus.Succeeded);
const destinationName = SECRET_SYNC_MAP[destination].name;
useEffect(() => {
if (importStatus === SecretSyncStatus.Succeeded) {
setTimeout(() => setHide(true), 3000);
} else {
setHide(false);
}
}, [importStatus]);
const failureMessage = useMemo(() => {
if (importStatus === SecretSyncStatus.Failed) {
if (lastImportMessage)
try {
return JSON.stringify(JSON.parse(lastImportMessage), null, 2);
} catch {
return lastImportMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [importStatus, lastImportMessage]);
if (!importStatus || hide) return null;
let variant: BadgeProps["variant"];
let label: string;
let icon: IconDefinition;
let tooltipContent: ReactNode;
switch (importStatus) {
case SecretSyncStatus.Pending:
case SecretSyncStatus.Running:
variant = "primary";
label = "Importing Secrets...";
tooltipContent = `Importing secrets from ${destinationName}. This may take a moment.`;
icon = faDownload;
break;
case SecretSyncStatus.Failed:
variant = "danger";
label = "Failed to Import Secrets";
icon = faTriangleExclamation;
tooltipContent = (
<div className="flex flex-col gap-2 whitespace-normal py-1">
{failureMessage && (
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">
{mini ? "Failed to Import Secrets" : "Failure Reason"}
</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
);
break;
case SecretSyncStatus.Succeeded:
default:
// only show success for a bit...
if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null;
tooltipContent = "Successfully imported secrets.";
variant = "success";
label = "Secrets Imported";
icon = faCheck;
}
return (
<Tooltip position="bottom" className="max-w-sm" content={tooltipContent}>
<div>
<Badge
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
variant={variant}
>
<FontAwesomeIcon icon={icon} />
{!mini && <span>{label}</span>}
</Badge>
</div>
</Tooltip>
);
};
@@ -0,0 +1,22 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
label: string;
children?: ReactNode;
className?: string;
labelClassName?: string;
};
export const SecretSyncLabel = ({ label, children, className, labelClassName }: Props) => {
return (
<div className={className}>
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
{children ? (
<p className="text-sm text-mineshaft-100">{children}</p>
) : (
<p className="text-sm italic text-mineshaft-400/50">None</p>
)}
</div>
);
};
@@ -0,0 +1,49 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
destination: SecretSync;
isConfigured: boolean;
};
export const SecretSyncModalHeader = ({ destination, isConfigured }: Props) => {
const destinationDetails = SECRET_SYNC_MAP[destination];
return (
<div className="flex w-full items-start gap-2">
<img
alt={`${destinationDetails.name} logo`}
src={`/images/integrations/${destinationDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{destinationDetails.name} Sync
<a
target="_blank"
href={`https://infisical.com/docs/integrations/secret-syncs/${destination}`}
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConfigured
? `Edit ${destinationDetails.name} Sync`
: `Sync secrets to ${destinationDetails.name}`}
</p>
</div>
</div>
);
};
@@ -0,0 +1,85 @@
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useTriggerSecretSyncRemoveSecrets } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync?: TSecretSync;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretSync: TSecretSync;
onComplete: () => void;
};
const Content = ({ secretSync, onComplete }: ContentProps) => {
const { id: syncId, destination } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const triggerSyncImport = useTriggerSecretSyncRemoveSecrets();
const handleTriggerRemoveSecrets = async () => {
try {
await triggerSyncImport.mutateAsync({
syncId,
destination
});
createNotification({
text: `Successfully triggered secret removal for ${destinationName} Sync`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to trigger secret removal for ${destinationName} Sync`,
type: "error"
});
}
};
return (
<>
<p className="mb-8 text-sm text-mineshaft-200">
Are you sure you want to remove synced secrets from this {destinationName} destination?
</p>
<div className="mt-8 flex w-full items-center justify-between gap-2">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
isDisabled={triggerSyncImport.isPending}
isLoading={triggerSyncImport.isPending}
onClick={handleTriggerRemoveSecrets}
colorSchema="secondary"
>
Remove Secrets
</Button>
</div>
</>
);
};
export const SecretSyncRemoveSecretsModal = ({ isOpen, onOpenChange, secretSync }: Props) => {
if (!secretSync) return null;
const destinationName = SECRET_SYNC_MAP[secretSync.destination].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Remove Secrets"
subTitle={`Remove synced secrets from this ${destinationName} Sync destination.`}
>
<Content secretSync={secretSync} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,112 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
import {
faCheck,
faEraser,
faTriangleExclamation,
faXmark,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { differenceInSeconds } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Badge, Tooltip } from "@app/components/v2";
import { BadgeProps } from "@app/components/v2/Badge/Badge";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
className?: string;
mini?: boolean;
};
export const SecretSyncRemoveStatusBadge = ({ secretSync, className, mini }: Props) => {
const { removeStatus, lastRemoveMessage, lastRemovedAt, destination } = secretSync;
const [hide, setHide] = useState(removeStatus === SecretSyncStatus.Succeeded);
const destinationName = SECRET_SYNC_MAP[destination].name;
useEffect(() => {
if (removeStatus === SecretSyncStatus.Succeeded) {
setTimeout(() => setHide(true), 3000);
} else {
setHide(false);
}
}, [removeStatus]);
const failureMessage = useMemo(() => {
if (removeStatus === SecretSyncStatus.Failed) {
if (lastRemoveMessage)
try {
return JSON.stringify(JSON.parse(lastRemoveMessage), null, 2);
} catch {
return lastRemoveMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [removeStatus, lastRemoveMessage]);
if (!removeStatus || hide) return null;
let variant: BadgeProps["variant"];
let label: string;
let icon: IconDefinition;
let tooltipContent: ReactNode;
switch (removeStatus) {
case SecretSyncStatus.Pending:
case SecretSyncStatus.Running:
variant = "primary";
label = "Removing Secrets...";
tooltipContent = `Removing secrets from ${destinationName}. This may take a moment.`;
icon = faEraser;
break;
case SecretSyncStatus.Failed:
variant = "danger";
label = "Failed to Remove Secrets";
icon = faTriangleExclamation;
tooltipContent = (
<div className="flex flex-col gap-2 whitespace-normal py-1">
{failureMessage && (
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">
{mini ? "Failed to Remove Secrets" : "Failure Reason"}
</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
);
break;
case SecretSyncStatus.Succeeded:
default:
// only show success for a bit...
if (lastRemovedAt && differenceInSeconds(new Date(), lastRemovedAt) > 15) return null;
tooltipContent = "Successfully removed secrets.";
variant = "success";
label = "Secrets Removed";
icon = faCheck;
}
return (
<Tooltip position="bottom" className="max-w-sm" content={tooltipContent}>
<div>
<Badge
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
variant={variant}
>
<FontAwesomeIcon icon={icon} />
{!mini && <span>{label}</span>}
</Badge>
</div>
</Tooltip>
);
};
@@ -0,0 +1,88 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync, useSecretSyncOptions } from "@app/hooks/api/secretSyncs";
type Props = {
onSelect: (destination: SecretSync) => void;
};
export const SecretSyncSelect = ({ onSelect }: Props) => {
const { isLoading, data: secretSyncOptions } = useSecretSyncOptions();
if (isLoading) {
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="grid grid-cols-4 gap-2">
{secretSyncOptions?.map(({ destination }) => {
const { image, name } = SECRET_SYNC_MAP[destination];
return (
<button
type="button"
key={destination}
onClick={() => onSelect(destination)}
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"
>
<img
src={`/images/integrations/${image}`}
height={40}
width={40}
className="mt-auto"
alt={`${name} logo`}
/>
<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>
);
})}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more services.</p>
<p>
{`If you don't see the third-party
service 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="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4 hover:bg-mineshaft-900/50">
<FontAwesomeIcon className="mt-auto text-3xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,47 @@
import {
faCheck,
faExclamationTriangle,
faRotate,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge";
import { SecretSyncStatus } from "@app/hooks/api/secretSyncs";
type Props = {
status: SecretSyncStatus;
} & Omit<BadgeProps, "children" | "variant">;
export const SecretSyncStatusBadge = ({ status }: Props) => {
let variant: BadgeProps["variant"];
let text: string;
let icon: IconDefinition;
switch (status) {
case SecretSyncStatus.Failed:
variant = "danger";
text = "Failed";
icon = faExclamationTriangle;
break;
case SecretSyncStatus.Succeeded:
variant = "success";
text = "Synced";
icon = faCheck;
break;
case SecretSyncStatus.Pending: // no need to differentiate from user perspective
case SecretSyncStatus.Running:
default:
variant = "primary";
text = "Syncing";
icon = faRotate;
break;
}
return (
<Badge className="flex h-5 w-min items-center gap-1.5" variant={variant}>
<FontAwesomeIcon icon={icon} />
<span>{text}</span>
</Badge>
);
};
@@ -0,0 +1,210 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Switch } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import {
SecretSync,
SecretSyncInitialSyncBehavior,
TSecretSync,
useCreateSecretSync,
useSecretSyncOption
} from "@app/hooks/api/secretSyncs";
import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas";
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields";
import { SecretSyncReviewFields } from "./SecretSyncReviewFields";
import { SecretSyncSourceFields } from "./SecretSyncSourceFields";
type Props = {
onComplete: (secretSync: TSecretSync) => void;
destination: SecretSync;
onCancel: () => void;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TSecretSyncForm)[] }[] = [
{ name: "Source", key: "source", fields: ["secretPath", "environment"] },
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] },
{ name: "Options", key: "options", fields: ["syncOptions"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
];
export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Props) => {
const createSecretSync = useCreateSecretSync();
const { currentWorkspace } = useWorkspace();
const { name: destinationName } = SECRET_SYNC_MAP[destination];
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const { syncOption } = useSecretSyncOption(destination);
const formMethods = useForm<TSecretSyncForm>({
resolver: zodResolver(SecretSyncFormSchema),
defaultValues: {
destination,
isEnabled: true,
syncOptions: {
initialSyncBehavior: syncOption?.canImportSecrets
? undefined
: SecretSyncInitialSyncBehavior.OverwriteDestination
}
},
reValidateMode: "onChange"
});
const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => {
try {
const secretSync = await createSecretSync.mutateAsync({
...formData,
connectionId: connection.id,
environment: environment.slug,
projectId: currentWorkspace.id
});
createNotification({
text: `Successfully added ${destinationName} Sync`,
type: "success"
});
onComplete(secretSync);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to add ${destinationName} Sync`,
text: err.message,
type: "error"
});
}
};
const handlePrev = () => {
if (selectedTabIndex === 0) {
onCancel();
return;
}
setSelectedTabIndex((prev) => prev - 1);
};
const { handleSubmit, trigger } = formMethods;
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
const handleNext = async () => {
if (isFinalStep) {
handleSubmit(onSubmit)();
return;
}
const isValid = await isStepValid(selectedTabIndex);
if (!isValid) return;
setSelectedTabIndex((prev) => prev + 1);
};
const isTabEnabled = async (index: number) => {
let isEnabled = true;
for (let i = index - 1; i >= 0; i -= 1) {
// eslint-disable-next-line no-await-in-loop
isEnabled = isEnabled && (await isStepValid(i));
}
return isEnabled;
};
const { control } = formMethods;
return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
<FormProvider {...formMethods}>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
{FORM_TABS.map((tab, index) => (
<Tab
onClick={async (e) => {
e.preventDefault();
const isEnabled = await isTabEnabled(index);
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
}}
className={({ selected }) =>
`w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} 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"
}`
}
key={tab.key}
>
{index + 1}. {tab.name}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<SecretSyncSourceFields />
</Tab.Panel>
<Tab.Panel>
<SecretSyncDestinationFields />
</Tab.Panel>
<Tab.Panel>
<SecretSyncOptionsFields />
<Controller
control={control}
name="isEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={
value
? "Secrets will automatically be synced when changes occur in the source location"
: "Secrets will not be synced until enabled"
}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="bg-red/50 shadow-inner data-[state=checked]:bg-green/50"
id="secret-sync-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p className="w-14">{value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
);
}}
/>
</Tab.Panel>
<Tab.Panel>
<SecretSyncDetailsFields />
</Tab.Panel>
<Tab.Panel>
<SecretSyncReviewFields />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button onClick={handleNext} colorSchema="secondary">
{isFinalStep ? "Create Sync" : "Next"}
</Button>
{selectedTabIndex > 0 && (
<Button onClick={handlePrev} colorSchema="secondary">
Back
</Button>
)}
</div>
</form>
);
};
@@ -0,0 +1,103 @@
import { ReactNode } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
import { Button, ModalClose } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs";
import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas";
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields";
import { SecretSyncSourceFields } from "./SecretSyncSourceFields";
type Props = {
onComplete: (secretSync: TSecretSync) => void;
secretSync: TSecretSync;
fields: SecretSyncEditFields;
};
export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => {
const updateSecretSync = useUpdateSecretSync();
const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination];
const formMethods = useForm<TSecretSyncForm>({
resolver: zodResolver(SecretSyncFormSchema),
defaultValues: {
...secretSync,
secretPath: secretSync.folder.path,
description: secretSync.description ?? ""
},
reValidateMode: "onChange"
});
const onSubmit = async ({ environment, ...formData }: TSecretSyncForm) => {
try {
const updatedSecretSync = await updateSecretSync.mutateAsync({
syncId: secretSync.id,
...formData,
environment: environment.slug
});
createNotification({
text: `Successfully updated ${destinationName} Sync`,
type: "success"
});
onComplete(updatedSecretSync);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${destinationName} Sync`,
text: err.message,
type: "error"
});
}
};
let Component: ReactNode;
switch (fields) {
case SecretSyncEditFields.Destination:
Component = <SecretSyncDestinationFields />;
break;
case SecretSyncEditFields.Options:
Component = <SecretSyncOptionsFields hideInitialSync={Boolean(secretSync.lastSyncedAt)} />;
break;
case SecretSyncEditFields.Source:
Component = <SecretSyncSourceFields />;
break;
case SecretSyncEditFields.Details:
default:
Component = <SecretSyncDetailsFields />;
break;
}
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = formMethods;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...formMethods}>{Component}</FormProvider>
<div className="flex w-full justify-between gap-4 pt-4">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
isLoading={isSubmitting}
isDisabled={!isDirty || isSubmitting}
type="submit"
colorSchema="secondary"
>
Update Sync
</Button>
</div>
</form>
);
};
@@ -0,0 +1,89 @@
import { Controller, useFormContext } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SECRET_SYNC_CONNECTION_MAP } from "@app/helpers/secretSyncs";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { TSecretSyncForm } from "./schemas";
type Props = {
onChange?: VoidFunction;
};
export const SecretSyncConnectionField = ({ onChange: callback }: Props) => {
const { permission } = useOrgPermission();
const { control, watch } = useFormContext<TSecretSyncForm>();
const destination = watch("destination");
const app = SECRET_SYNC_CONNECTION_MAP[destination];
const { data: options, isLoading } = useListAvailableAppConnections(app);
const connectionName = APP_CONNECTION_MAP[app].name;
const canCreateConnection = permission.can(
OrgPermissionActions.Create,
OrgPermissionSubjects.AppConnections
);
const appName = APP_CONNECTION_MAP[SECRET_SYNC_CONNECTION_MAP[destination]].name;
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Specify the {appName} Connection to use to connect to {connectionName} and configure
destination parameters.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="App Connections can be created from the Organization Settings page."
isError={Boolean(error)}
errorText={error?.message}
label={`${connectionName} Connection`}
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
if (callback) callback();
}}
isLoading={isLoading}
options={options}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="connection"
/>
{options?.length === 0 && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? (
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link
to="/organization/settings"
className="underline"
search={{ selectedTab: "app-connections" }}
>
Organization Settings
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p>
)}
</>
);
};
@@ -0,0 +1,67 @@
import { Controller, useFormContext } from "react-hook-form";
import { components, OptionProps, SingleValue } from "react-select";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { Badge, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
const Option = ({ isSelected, children, ...props }: OptionProps<(typeof AWS_REGIONS)[number]>) => {
return (
<components.Option isSelected={isSelected} {...props}>
<div className="flex flex-row items-center justify-between">
<p className="truncate">{children}</p>
<Badge variant="success" className="ml-1 mr-auto cursor-pointer">
{props.data.slug}
</Badge>
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</div>
</components.Option>
);
};
export const AwsParameterStoreSyncFields = () => {
const { control } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
return (
<>
<SecretSyncConnectionField />
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Region">
<FilterableSelect
value={AWS_REGIONS.find((region) => region.slug === value)}
onChange={(option) =>
onChange((option as SingleValue<(typeof AWS_REGIONS)[number]>)?.slug)
}
options={AWS_REGIONS}
placeholder="Select region..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
components={{ Option }}
/>
</FormControl>
)}
control={control}
name="destinationConfig.region"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Path">
<Input value={value} onChange={onChange} placeholder="Path..." />
</FormControl>
)}
control={control}
name="destinationConfig.path"
/>
</>
);
};
@@ -0,0 +1,234 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { MultiValue, SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import {
TGitHubConnectionEnvironment,
TGitHubConnectionOrganization,
TGitHubConnectionRepository,
useGitHubConnectionListEnvironments,
useGitHubConnectionListOrganizations,
useGitHubConnectionListRepositories
} from "@app/hooks/api/appConnections/github";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync";
import { TSecretSyncForm } from "../schemas";
export const GitHubSyncFields = () => {
const {
control,
formState: { errors },
watch,
setValue
} = useFormContext<TSecretSyncForm & { destination: SecretSync.GitHub }>();
console.log("errors", errors, watch());
const connectionId = useWatch({ name: "connection.id", control });
const currentScope = watch("destinationConfig.scope");
const currentVisibility = watch("destinationConfig.visibility", GitHubSyncVisibility.All);
const currentOrg = watch("destinationConfig.org");
const currentRepo = watch("destinationConfig.repo");
const currentOwner = watch("destinationConfig.owner");
const { data: repositories = [], isPending: isRepositoriesPending } =
useGitHubConnectionListRepositories(connectionId, {
enabled: Boolean(connectionId)
});
const { data: organizations = [], isPending: isOrganizationsPending } =
useGitHubConnectionListOrganizations(connectionId, {
enabled: Boolean(connectionId && currentScope === GitHubSyncScope.Organization)
});
const { data: environments = [], isPending: isEnvironmentsPending } =
useGitHubConnectionListEnvironments(
{
connectionId,
repo: currentRepo,
owner: currentOwner
},
{
enabled: Boolean(
connectionId &&
currentRepo &&
currentOwner &&
currentScope === GitHubSyncScope.RepositoryEnvironment
)
}
);
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.org", "");
setValue("destinationConfig.repo", "");
setValue("destinationConfig.owner", "");
setValue("destinationConfig.selectedRepositoryIds", []);
}}
/>
<Controller
name="destinationConfig.scope"
control={control}
defaultValue={GitHubSyncScope.Repository}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Scope">
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select a scope..."
dropdownContainerClassName="max-w-none"
>
{Object.values(GitHubSyncScope).map((scope) => (
<SelectItem className="capitalize" value={scope} key={scope}>
{scope.replace("-", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{currentScope === GitHubSyncScope.Organization && (
<>
<Controller
name="destinationConfig.org"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Organization">
<FilterableSelect
isLoading={isOrganizationsPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={organizations.find((org) => org.login === value) ?? null}
onChange={(option) =>
onChange((option as SingleValue<TGitHubConnectionOrganization>)?.login ?? null)
}
options={organizations}
placeholder="Select an organization..."
getOptionLabel={(option) => option.login}
getOptionValue={(option) => option.login}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.visibility"
control={control}
defaultValue={GitHubSyncVisibility.All}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Visibility"
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select visibility..."
dropdownContainerClassName="max-w-none"
>
{Object.values(GitHubSyncVisibility).map((scope) => (
<SelectItem className="capitalize" value={scope} key={scope}>
{scope.replace("-", " ")} Repositories
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{currentVisibility === GitHubSyncVisibility.Selected && (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Selected Repositories"
>
<FilterableSelect
menuPlacement="top"
isLoading={isRepositoriesPending && Boolean(currentOrg)}
isDisabled={!currentOrg || !connectionId}
isMulti
value={repositories.filter((repo) => value?.includes(repo.id))}
onChange={(option) => {
const repos = option as MultiValue<TGitHubConnectionRepository>;
onChange(repos.map((repo) => repo.id));
}}
options={repositories.filter((repo) => repo.owner.login === currentOrg)}
placeholder="Select one or more repositories..."
getOptionLabel={(option) => `${option.owner.login}/${option.name}`}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
control={control}
name="destinationConfig.selectedRepositoryIds"
/>
)}
</>
)}
{currentScope !== GitHubSyncScope.Organization && (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Repository">
<FilterableSelect
menuPlacement="top"
isLoading={isRepositoriesPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={repositories.find((repo) => repo.name === value) ?? null}
onChange={(option) => {
const repo = option as SingleValue<TGitHubConnectionRepository>;
onChange(repo?.name);
setValue("destinationConfig.owner", repo?.owner.login ?? "");
}}
options={repositories}
placeholder="Select a repository..."
getOptionLabel={(option) => `${option.owner.login}/${option.name}`}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
control={control}
name="destinationConfig.repo"
/>
)}
{currentScope === GitHubSyncScope.RepositoryEnvironment && (
<Controller
name="destinationConfig.env"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Environment">
<FilterableSelect
menuPlacement="top"
isLoading={isEnvironmentsPending && Boolean(connectionId) && Boolean(currentRepo)}
isDisabled={!connectionId || !currentRepo}
value={environments.find((env) => env.name === value) ?? null}
onChange={(option) =>
onChange((option as SingleValue<TGitHubConnectionEnvironment>)?.name ?? null)
}
options={environments}
placeholder="Select an environment..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
)}
</>
);
};
@@ -0,0 +1,22 @@
import { useFormContext } from "react-hook-form";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields";
import { GitHubSyncFields } from "./GitHubSyncFields";
export const SecretSyncDestinationFields = () => {
const { watch } = useFormContext<TSecretSyncForm>();
const destination = watch("destination");
switch (destination) {
case SecretSync.AWSParameterStore:
return <AwsParameterStoreSyncFields />;
case SecretSync.GitHub:
return <GitHubSyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
};
@@ -0,0 +1 @@
export * from "./SecretSyncDestinationFields";
@@ -0,0 +1,51 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { TSecretSyncForm } from "./schemas";
export const SecretSyncDetailsFields = () => {
const { control } = useFormContext<TSecretSyncForm>();
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Provide a name and description for this Secret Sync.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
helperText="Must be slug-friendly"
isError={Boolean(error)}
errorText={error?.message}
label="Name"
>
<Input value={value} onChange={onChange} placeholder="my-secret-sync" />
</FormControl>
)}
control={control}
name="name"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Description"
>
<TextArea
value={value}
onChange={onChange}
placeholder="Describe the purpose of this sync..."
className="!resize-none"
rows={4}
/>
</FormControl>
)}
control={control}
name="description"
/>
</>
);
};
@@ -0,0 +1,124 @@
import { Controller, useFormContext } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { useSecretSyncOption } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "./schemas";
type Props = {
hideInitialSync?: boolean;
};
export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
const { control, watch } = useFormContext<TSecretSyncForm>();
const destination = watch("destination");
const destinationName = SECRET_SYNC_MAP[destination].name;
const { syncOption } = useSecretSyncOption(destination);
return (
<>
<p className="mb-4 text-sm text-bunker-300">Configure how secrets should be synced.</p>
{!hideInitialSync && (
<>
<Controller
name="syncOptions.initialSyncBehavior"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipClassName="max-w-lg py-3"
tooltipText={
syncOption?.canImportSecrets ? (
<div className="flex flex-col gap-3">
<p>
Specify how Infisical should resolve the initial sync to {destinationName}.
The following options are available:
</p>
<ul className="flex list-disc flex-col gap-3 pl-4">
{Object.values(SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP).map((details) => {
const { name, description } = details(destinationName);
return (
<li key={name}>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">{name}</span>:{" "}
{description}
</p>
</li>
);
})}
</ul>
</div>
) : undefined
}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Initial Sync Behavior"
>
<Select
isDisabled={!syncOption?.canImportSecrets}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
placeholder="Select an option..."
dropdownContainerClassName="max-w-none"
>
{Object.entries(SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP).map(([key, details]) => {
const { name } = details(destinationName);
return (
<SelectItem value={key} key={key}>
{name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{!syncOption?.canImportSecrets && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faTriangleExclamation} />
{destinationName} only supports overwriting destination secrets. Secrets not present
in Infisical will be removed from the destination.
</p>
)}
</>
)}
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Prepend Prefix"
>
<Input className="uppercase" value={value} onChange={onChange} placeholder="INF_" />
</FormControl>
)}
control={control}
name="syncOptions.prependPrefix"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Append Suffix"
>
<Input className="uppercase" value={value} onChange={onChange} placeholder="_INF" />
</FormControl>
)}
control={control}
name="syncOptions.appendSuffix"
/>
</>
);
};
@@ -0,0 +1,29 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AwsParameterStoreSyncReviewFields = () => {
const { watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
const [region, path] = watch(["destinationConfig.region", "destinationConfig.path"]);
const awsRegion = AWS_REGIONS.find((r) => r.slug === region);
return (
<>
<SecretSyncLabel label="Region">
{awsRegion?.name}
<Badge className="ml-1" variant="success">
{awsRegion?.slug}{" "}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Path">{path}</SecretSyncLabel>
</>
);
};
@@ -0,0 +1,61 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
export const GitHubSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.GitHub }>();
const config = watch("destinationConfig");
let ScopeComponents: ReactNode;
switch (config.scope) {
case GitHubSyncScope.Repository:
ScopeComponents = (
<SecretSyncLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
);
break;
case GitHubSyncScope.Organization:
ScopeComponents = (
<>
<SecretSyncLabel label="Organization">{config.org}</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Visibility">
{config.visibility}
</SecretSyncLabel>
</>
);
break;
case GitHubSyncScope.RepositoryEnvironment:
ScopeComponents = (
<>
<SecretSyncLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Environment">
{config.env}
</SecretSyncLabel>
</>
);
break;
default:
throw new Error(
`Unhandled GitHub Sync Review Field Scope ${(config as TGitHubSync["destinationConfig"]).scope}`
);
}
return (
<>
<SecretSyncLabel className="capitalize" label="Scope">
{config.scope.replace("-", " ")}
</SecretSyncLabel>
{ScopeComponents}
</>
);
};
@@ -0,0 +1,90 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
export const SecretSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm>();
let DestinationFieldsComponent: ReactNode;
const {
name,
description,
connection,
environment,
secretPath,
syncOptions: { appendSuffix, prependPrefix, initialSyncBehavior },
destination,
isEnabled
} = watch();
const destinationName = SECRET_SYNC_MAP[destination].name;
switch (destination) {
case SecretSync.AWSParameterStore:
DestinationFieldsComponent = <AwsParameterStoreSyncReviewFields />;
break;
case SecretSync.GitHub:
DestinationFieldsComponent = <GitHubSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
return (
<div className="mb-4 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Source</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Environment">{environment.name}</SecretSyncLabel>
<SecretSyncLabel label="Secret Path">{secretPath}</SecretSyncLabel>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Destination</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Connection">{connection.name}</SecretSyncLabel>
{DestinationFieldsComponent}
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Options</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Sync Enabled">
<Badge variant={isEnabled ? "success" : "danger"}>
{isEnabled ? "Enabled" : "Disabled"}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destinationName).name}
</SecretSyncLabel>
<SecretSyncLabel label="Prepend Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Append Suffix">{appendSuffix}</SecretSyncLabel>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Name">{name}</SecretSyncLabel>
<SecretSyncLabel label="Description">{description}</SecretSyncLabel>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretSyncReviewFields";
@@ -0,0 +1,55 @@
import { Controller, useFormContext } from "react-hook-form";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { TSecretSyncForm } from "./schemas";
export const SecretSyncSourceFields = () => {
const { control, watch } = useFormContext<TSecretSyncForm>();
const { currentWorkspace } = useWorkspace();
const selectedEnvironment = watch("environment");
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Specify the environment and path where you would like to sync secrets from.
</p>
<Controller
defaultValue={currentWorkspace.environments[0]}
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Environment" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
value={value}
onChange={onChange}
options={currentWorkspace.environments}
placeholder="Select environment..."
getOptionLabel={(option) => option?.name}
getOptionValue={(option) => option?.id}
/>
</FormControl>
)}
/>
<Controller
defaultValue="/"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Secret Path">
<SecretPathInput
environment={selectedEnvironment?.slug}
value={value}
onChange={onChange}
/>
</FormControl>
)}
control={control}
name="secretPath"
/>
</>
);
};
@@ -0,0 +1,2 @@
export * from "./CreateSecretSyncForm";
export * from "./EditSecretSyncForm";
@@ -0,0 +1,28 @@
import { z } from "zod";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AwsParameterStoreSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: z.object({
path: z
.string()
.min(1, "Parameter Store Path required")
.superRefine((val, ctx) => {
if (!/^\/([/]|(([\w-]+\/)+))?$/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid path - must follow "/example/path/" format'
});
}
if (val.length > 2048) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Cannot exceed 2048 characters"
});
}
}),
region: z.string().min(1, "Region required")
})
});
@@ -0,0 +1,45 @@
import { z } from "zod";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync";
export const GitHubSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.GitHub),
destinationConfig: z
.discriminatedUnion("scope", [
z.object({
scope: z.literal(GitHubSyncScope.Organization),
org: z.string().min(1, "Organization name required"),
visibility: z.nativeEnum(GitHubSyncVisibility),
selectedRepositoryIds: z.number().array().optional()
}),
z.object({
scope: z.literal(GitHubSyncScope.Repository),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required")
}),
z.object({
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required"),
env: z.string().min(1, "Environment name required")
})
])
.superRefine((options, ctx) => {
if (options.scope !== GitHubSyncScope.Organization) return;
if (
options.visibility === GitHubSyncVisibility.Selected &&
!options.selectedRepositoryIds?.length
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Select at least 1 repository",
path: ["selectedRepositoryIds"]
});
}
})
});
@@ -0,0 +1 @@
export * from "./secret-sync-schema";
@@ -0,0 +1,39 @@
import { z } from "zod";
import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema";
import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs";
import { slugSchema } from "@app/lib/schemas";
import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema";
const BaseSecretSyncSchema = z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(),
connection: z.object({ name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
secretPath: z.string().min(1, "Secret path required"),
syncOptions: z.object({
initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior),
prependPrefix: z
.string()
.trim()
.transform((str) => str.toUpperCase())
.optional(),
appendSuffix: z
.string()
.trim()
.transform((str) => str.toUpperCase())
.optional()
}),
isEnabled: z.boolean()
});
// TODO: union once more supported
export const SecretSyncFormSchema = z
.discriminatedUnion("destination", [
AwsParameterStoreSyncDestinationSchema,
GitHubSyncDestinationSchema
])
.and(BaseSecretSyncSchema);
export type TSecretSyncForm = z.infer<typeof SecretSyncFormSchema>;
@@ -0,0 +1,41 @@
import { twMerge } from "tailwind-merge";
import { useGitHubConnectionListRepositories } from "@app/hooks/api/appConnections/github";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
type Props = {
secretSync: TGitHubSync;
};
export const GitHubSyncSelectedRepositoriesTooltipContent = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
const showRepositories =
destinationConfig.scope === GitHubSyncScope.Organization &&
Boolean(destinationConfig.selectedRepositoryIds?.length);
const { data: repositories, isPending } = useGitHubConnectionListRepositories(
secretSync.connectionId,
{
enabled: showRepositories
}
);
if (destinationConfig.scope === GitHubSyncScope.Organization) {
return (
<>
<span className="text-xs text-bunker-300">Repositories:</span>
<p className={twMerge("text-sm", isPending && "text-mineshaft-400")}>
{isPending
? "Loading..."
: repositories
?.filter((repo) => destinationConfig?.selectedRepositoryIds?.includes(repo.id))
.map((repo) => repo.name)
.join(", ")}
</p>
</>
);
}
return null;
};
@@ -0,0 +1,9 @@
export * from "./CreateSecretSyncModal";
export * from "./DeleteSecretSyncModal";
export * from "./EditSecretSyncModal";
export * from "./SecretSyncImportSecretsModal";
export * from "./SecretSyncImportStatusBadge";
export * from "./SecretSyncLabel";
export * from "./SecretSyncRemoveSecretsModal";
export * from "./SecretSyncRemoveStatusBadge";
export * from "./SecretSyncStatusBadge";
@@ -0,0 +1,6 @@
export enum SecretSyncEditFields {
Details = "details",
Options = "options",
Source = "source",
Destination = "destination"
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge";
import { Card, CardBody, CardFooter, CardTitle } from "../Card";
import { IconButton } from "../IconButton";
export type ModalContentProps = DialogPrimitive.DialogContentProps & {
export type ModalContentProps = Omit<DialogPrimitive.DialogContentProps, "title"> & {
title?: ReactNode;
subTitle?: ReactNode;
footerContent?: ReactNode;
+8 -1
View File
@@ -9,6 +9,7 @@ export type SwitchProps = Omit<SwitchPrimitive.SwitchProps, "checked" | "disable
isRequired?: boolean;
isDisabled?: boolean;
containerClassName?: string;
thumbClassName?: string;
};
export const Switch = ({
@@ -19,6 +20,7 @@ export const Switch = ({
isDisabled,
isRequired,
containerClassName,
thumbClassName,
...props
}: SwitchProps): JSX.Element => (
<div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
@@ -38,7 +40,12 @@ export const Switch = ({
)}
id={id}
>
<SwitchPrimitive.Thumb className="block h-4 w-4 translate-x-0.5 rounded-full border-none bg-black shadow transition-all will-change-transform data-[state=checked]:translate-x-[18px]" />
<SwitchPrimitive.Thumb
className={twMerge(
"block h-4 w-4 translate-x-0.5 rounded-full border-none bg-black shadow transition-all will-change-transform data-[state=checked]:translate-x-[18px]",
thumbClassName
)}
/>
</SwitchPrimitive.Root>
</div>
);