mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 15:40:47 +00:00
feature: secret sync base + aws parameter store & github
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+67
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+234
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+22
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+29
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+61
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+90
@@ -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";
|
||||
+28
@@ -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>;
|
||||
+41
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user