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