feature: secret sync base + aws parameter store & github

This commit is contained in:
Scott Wilson
2025-01-15 21:52:50 -08:00
parent b440e918ac
commit 473efa91f0
214 changed files with 10212 additions and 623 deletions
@@ -0,0 +1,69 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { CreateSecretSyncForm } from "./forms";
import { SecretSyncModalHeader } from "./SecretSyncModalHeader";
import { SecretSyncSelect } from "./SecretSyncSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onComplete: (secretSync: TSecretSync) => void;
selectedSync: SecretSync | null;
setSelectedSync: (selectedSync: SecretSync | null) => void;
};
const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => {
if (selectedSync) {
return (
<CreateSecretSyncForm
onComplete={onComplete}
onCancel={() => setSelectedSync(null)}
destination={selectedSync}
/>
);
}
return <SecretSyncSelect onSelect={setSelectedSync} />;
};
export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => {
const [selectedSync, setSelectedSync] = useState<SecretSync | null>(null);
return (
<Modal
{...props}
onOpenChange={(isOpen) => {
if (!isOpen) setSelectedSync(null);
onOpenChange(isOpen);
}}
>
<ModalContent
title={
selectedSync ? (
<SecretSyncModalHeader isConfigured={false} destination={selectedSync} />
) : (
"Add Sync"
)
}
className="max-w-2xl"
subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."}
bodyClassName="overflow-visible"
>
<Content
onComplete={() => {
setSelectedSync(null);
onOpenChange(false);
}}
selectedSync={selectedSync}
setSelectedSync={setSelectedSync}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,55 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useDeleteSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync?: TSecretSync;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: () => void;
};
export const DeleteSecretSyncModal = ({ isOpen, onOpenChange, secretSync, onComplete }: Props) => {
const deleteSync = useDeleteSecretSync();
if (!secretSync) return null;
const { id: syncId, name, destination } = secretSync;
const handleDeleteSecretSync = async () => {
const destinationName = SECRET_SYNC_MAP[destination].name;
try {
await deleteSync.mutateAsync({
syncId,
destination
});
createNotification({
text: `Successfully removed ${destinationName} Sync`,
type: "success"
});
if (onComplete) onComplete();
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: `Failed remove ${destinationName} Sync`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDeleteSecretSync}
/>
);
};
@@ -0,0 +1,33 @@
import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
import { Modal, ModalContent } from "@app/components/v2";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
import { EditSecretSyncForm } from "./forms";
import { SecretSyncModalHeader } from "./SecretSyncModalHeader";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
secretSync?: TSecretSync;
fields: SecretSyncEditFields;
};
export const EditSecretSyncModal = ({ secretSync, onOpenChange, fields, ...props }: Props) => {
if (!secretSync) return null;
return (
<Modal {...props} onOpenChange={onOpenChange}>
<ModalContent
title={<SecretSyncModalHeader isConfigured destination={secretSync.destination} />}
className="max-w-2xl"
bodyClassName="overflow-visible"
>
<EditSecretSyncForm
onComplete={() => onOpenChange(false)}
fields={fields}
secretSync={secretSync}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,167 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SECRET_SYNC_IMPORT_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import {
SecretSyncImportBehavior,
TSecretSync,
useTriggerSecretSyncImportSecrets
} from "@app/hooks/api/secretSyncs";
type Props = {
secretSync?: TSecretSync;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretSync: TSecretSync;
onComplete: () => void;
};
const FormSchema = z.object({
importBehavior: z.nativeEnum(SecretSyncImportBehavior)
});
type TFormData = z.infer<typeof FormSchema>;
const Content = ({ secretSync, onComplete }: ContentProps) => {
const { id: syncId, destination } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = useForm<TFormData>({ resolver: zodResolver(FormSchema) });
const triggerImportSecrets = useTriggerSecretSyncImportSecrets();
const handleTriggerImportSecrets = async ({ importBehavior }: TFormData) => {
try {
await triggerImportSecrets.mutateAsync({
syncId,
destination,
importBehavior
});
createNotification({
text: `Successfully triggered secret import for ${destinationName} Sync`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to trigger secret import for ${destinationName} Sync`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(handleTriggerImportSecrets)}>
<p className="mb-8 text-sm text-mineshaft-200">
Are you sure you want to import secrets from this {destinationName} destination into
Infiscal?
</p>
<Controller
name="importBehavior"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipClassName="max-w-lg py-3"
tooltipText={
<div className="flex flex-col gap-3">
<p>
Specify how Infisical should resolve the initial sync to {destinationName}. The
following options are available:
</p>
<ul className="flex list-disc flex-col gap-3 pl-4">
{Object.values(SECRET_SYNC_IMPORT_BEHAVIOR_MAP).map((details) => {
const { name, description } = details(destinationName);
return (
<li key={name}>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">{name}</span>: {description}
</p>
</li>
);
})}
</ul>
</div>
}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Initial Sync Behavior"
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
placeholder="Select an option..."
dropdownContainerClassName="max-w-none"
>
{Object.entries(SECRET_SYNC_IMPORT_BEHAVIOR_MAP).map(([key, details]) => {
const { name } = details(destinationName);
return (
<SelectItem value={key} key={key}>
{name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex w-full items-center justify-between gap-2">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
type="submit"
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
colorSchema="secondary"
>
Import Secrets
</Button>
</div>
</form>
);
};
export const SecretSyncImportSecretsModal = ({ isOpen, onOpenChange, secretSync }: Props) => {
if (!secretSync) return null;
const destinationName = SECRET_SYNC_MAP[secretSync.destination].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Import Secrets"
subTitle={`Import secrets into Infisical from this ${destinationName} Sync destination.`}
>
<Content secretSync={secretSync} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,112 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
import {
faCheck,
faDownload,
faTriangleExclamation,
faXmark,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { differenceInSeconds } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Badge, Tooltip } from "@app/components/v2";
import { BadgeProps } from "@app/components/v2/Badge/Badge";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
className?: string;
mini?: boolean;
};
export const SecretSyncImportStatusBadge = ({ secretSync, className, mini }: Props) => {
const { importStatus, lastImportMessage, lastImportedAt, destination } = secretSync;
const [hide, setHide] = useState(importStatus === SecretSyncStatus.Succeeded);
const destinationName = SECRET_SYNC_MAP[destination].name;
useEffect(() => {
if (importStatus === SecretSyncStatus.Succeeded) {
setTimeout(() => setHide(true), 3000);
} else {
setHide(false);
}
}, [importStatus]);
const failureMessage = useMemo(() => {
if (importStatus === SecretSyncStatus.Failed) {
if (lastImportMessage)
try {
return JSON.stringify(JSON.parse(lastImportMessage), null, 2);
} catch {
return lastImportMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [importStatus, lastImportMessage]);
if (!importStatus || hide) return null;
let variant: BadgeProps["variant"];
let label: string;
let icon: IconDefinition;
let tooltipContent: ReactNode;
switch (importStatus) {
case SecretSyncStatus.Pending:
case SecretSyncStatus.Running:
variant = "primary";
label = "Importing Secrets...";
tooltipContent = `Importing secrets from ${destinationName}. This may take a moment.`;
icon = faDownload;
break;
case SecretSyncStatus.Failed:
variant = "danger";
label = "Failed to Import Secrets";
icon = faTriangleExclamation;
tooltipContent = (
<div className="flex flex-col gap-2 whitespace-normal py-1">
{failureMessage && (
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">
{mini ? "Failed to Import Secrets" : "Failure Reason"}
</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
);
break;
case SecretSyncStatus.Succeeded:
default:
// only show success for a bit...
if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null;
tooltipContent = "Successfully imported secrets.";
variant = "success";
label = "Secrets Imported";
icon = faCheck;
}
return (
<Tooltip position="bottom" className="max-w-sm" content={tooltipContent}>
<div>
<Badge
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
variant={variant}
>
<FontAwesomeIcon icon={icon} />
{!mini && <span>{label}</span>}
</Badge>
</div>
</Tooltip>
);
};
@@ -0,0 +1,22 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
label: string;
children?: ReactNode;
className?: string;
labelClassName?: string;
};
export const SecretSyncLabel = ({ label, children, className, labelClassName }: Props) => {
return (
<div className={className}>
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
{children ? (
<p className="text-sm text-mineshaft-100">{children}</p>
) : (
<p className="text-sm italic text-mineshaft-400/50">None</p>
)}
</div>
);
};
@@ -0,0 +1,49 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
destination: SecretSync;
isConfigured: boolean;
};
export const SecretSyncModalHeader = ({ destination, isConfigured }: Props) => {
const destinationDetails = SECRET_SYNC_MAP[destination];
return (
<div className="flex w-full items-start gap-2">
<img
alt={`${destinationDetails.name} logo`}
src={`/images/integrations/${destinationDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{destinationDetails.name} Sync
<a
target="_blank"
href={`https://infisical.com/docs/integrations/secret-syncs/${destination}`}
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConfigured
? `Edit ${destinationDetails.name} Sync`
: `Sync secrets to ${destinationDetails.name}`}
</p>
</div>
</div>
);
};
@@ -0,0 +1,85 @@
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useTriggerSecretSyncRemoveSecrets } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync?: TSecretSync;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretSync: TSecretSync;
onComplete: () => void;
};
const Content = ({ secretSync, onComplete }: ContentProps) => {
const { id: syncId, destination } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const triggerSyncImport = useTriggerSecretSyncRemoveSecrets();
const handleTriggerRemoveSecrets = async () => {
try {
await triggerSyncImport.mutateAsync({
syncId,
destination
});
createNotification({
text: `Successfully triggered secret removal for ${destinationName} Sync`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to trigger secret removal for ${destinationName} Sync`,
type: "error"
});
}
};
return (
<>
<p className="mb-8 text-sm text-mineshaft-200">
Are you sure you want to remove synced secrets from this {destinationName} destination?
</p>
<div className="mt-8 flex w-full items-center justify-between gap-2">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
isDisabled={triggerSyncImport.isPending}
isLoading={triggerSyncImport.isPending}
onClick={handleTriggerRemoveSecrets}
colorSchema="secondary"
>
Remove Secrets
</Button>
</div>
</>
);
};
export const SecretSyncRemoveSecretsModal = ({ isOpen, onOpenChange, secretSync }: Props) => {
if (!secretSync) return null;
const destinationName = SECRET_SYNC_MAP[secretSync.destination].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Remove Secrets"
subTitle={`Remove synced secrets from this ${destinationName} Sync destination.`}
>
<Content secretSync={secretSync} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,112 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
import {
faCheck,
faEraser,
faTriangleExclamation,
faXmark,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { differenceInSeconds } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Badge, Tooltip } from "@app/components/v2";
import { BadgeProps } from "@app/components/v2/Badge/Badge";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
className?: string;
mini?: boolean;
};
export const SecretSyncRemoveStatusBadge = ({ secretSync, className, mini }: Props) => {
const { removeStatus, lastRemoveMessage, lastRemovedAt, destination } = secretSync;
const [hide, setHide] = useState(removeStatus === SecretSyncStatus.Succeeded);
const destinationName = SECRET_SYNC_MAP[destination].name;
useEffect(() => {
if (removeStatus === SecretSyncStatus.Succeeded) {
setTimeout(() => setHide(true), 3000);
} else {
setHide(false);
}
}, [removeStatus]);
const failureMessage = useMemo(() => {
if (removeStatus === SecretSyncStatus.Failed) {
if (lastRemoveMessage)
try {
return JSON.stringify(JSON.parse(lastRemoveMessage), null, 2);
} catch {
return lastRemoveMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [removeStatus, lastRemoveMessage]);
if (!removeStatus || hide) return null;
let variant: BadgeProps["variant"];
let label: string;
let icon: IconDefinition;
let tooltipContent: ReactNode;
switch (removeStatus) {
case SecretSyncStatus.Pending:
case SecretSyncStatus.Running:
variant = "primary";
label = "Removing Secrets...";
tooltipContent = `Removing secrets from ${destinationName}. This may take a moment.`;
icon = faEraser;
break;
case SecretSyncStatus.Failed:
variant = "danger";
label = "Failed to Remove Secrets";
icon = faTriangleExclamation;
tooltipContent = (
<div className="flex flex-col gap-2 whitespace-normal py-1">
{failureMessage && (
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">
{mini ? "Failed to Remove Secrets" : "Failure Reason"}
</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
);
break;
case SecretSyncStatus.Succeeded:
default:
// only show success for a bit...
if (lastRemovedAt && differenceInSeconds(new Date(), lastRemovedAt) > 15) return null;
tooltipContent = "Successfully removed secrets.";
variant = "success";
label = "Secrets Removed";
icon = faCheck;
}
return (
<Tooltip position="bottom" className="max-w-sm" content={tooltipContent}>
<div>
<Badge
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
variant={variant}
>
<FontAwesomeIcon icon={icon} />
{!mini && <span>{label}</span>}
</Badge>
</div>
</Tooltip>
);
};
@@ -0,0 +1,88 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync, useSecretSyncOptions } from "@app/hooks/api/secretSyncs";
type Props = {
onSelect: (destination: SecretSync) => void;
};
export const SecretSyncSelect = ({ onSelect }: Props) => {
const { isLoading, data: secretSyncOptions } = useSecretSyncOptions();
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="grid grid-cols-4 gap-2">
{secretSyncOptions?.map(({ destination }) => {
const { image, name } = SECRET_SYNC_MAP[destination];
return (
<button
type="button"
key={destination}
onClick={() => onSelect(destination)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
height={40}
width={40}
className="mt-auto"
alt={`${name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more services.</p>
<p>
{`If you don't see the third-party
service you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4 hover:bg-mineshaft-900/50">
<FontAwesomeIcon className="mt-auto text-3xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,47 @@
import {
faCheck,
faExclamationTriangle,
faRotate,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge";
import { SecretSyncStatus } from "@app/hooks/api/secretSyncs";
type Props = {
status: SecretSyncStatus;
} & Omit<BadgeProps, "children" | "variant">;
export const SecretSyncStatusBadge = ({ status }: Props) => {
let variant: BadgeProps["variant"];
let text: string;
let icon: IconDefinition;
switch (status) {
case SecretSyncStatus.Failed:
variant = "danger";
text = "Failed";
icon = faExclamationTriangle;
break;
case SecretSyncStatus.Succeeded:
variant = "success";
text = "Synced";
icon = faCheck;
break;
case SecretSyncStatus.Pending: // no need to differentiate from user perspective
case SecretSyncStatus.Running:
default:
variant = "primary";
text = "Syncing";
icon = faRotate;
break;
}
return (
<Badge className="flex h-5 w-min items-center gap-1.5" variant={variant}>
<FontAwesomeIcon icon={icon} />
<span>{text}</span>
</Badge>
);
};
@@ -0,0 +1,210 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Switch } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import {
SecretSync,
SecretSyncInitialSyncBehavior,
TSecretSync,
useCreateSecretSync,
useSecretSyncOption
} from "@app/hooks/api/secretSyncs";
import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas";
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields";
import { SecretSyncReviewFields } from "./SecretSyncReviewFields";
import { SecretSyncSourceFields } from "./SecretSyncSourceFields";
type Props = {
onComplete: (secretSync: TSecretSync) => void;
destination: SecretSync;
onCancel: () => void;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TSecretSyncForm)[] }[] = [
{ name: "Source", key: "source", fields: ["secretPath", "environment"] },
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] },
{ name: "Options", key: "options", fields: ["syncOptions"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
];
export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Props) => {
const createSecretSync = useCreateSecretSync();
const { currentWorkspace } = useWorkspace();
const { name: destinationName } = SECRET_SYNC_MAP[destination];
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const { syncOption } = useSecretSyncOption(destination);
const formMethods = useForm<TSecretSyncForm>({
resolver: zodResolver(SecretSyncFormSchema),
defaultValues: {
destination,
isEnabled: true,
syncOptions: {
initialSyncBehavior: syncOption?.canImportSecrets
? undefined
: SecretSyncInitialSyncBehavior.OverwriteDestination
}
},
reValidateMode: "onChange"
});
const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => {
try {
const secretSync = await createSecretSync.mutateAsync({
...formData,
connectionId: connection.id,
environment: environment.slug,
projectId: currentWorkspace.id
});
createNotification({
text: `Successfully added ${destinationName} Sync`,
type: "success"
});
onComplete(secretSync);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to add ${destinationName} Sync`,
text: err.message,
type: "error"
});
}
};
const handlePrev = () => {
if (selectedTabIndex === 0) {
onCancel();
return;
}
setSelectedTabIndex((prev) => prev - 1);
};
const { handleSubmit, trigger } = formMethods;
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
const handleNext = async () => {
if (isFinalStep) {
handleSubmit(onSubmit)();
return;
}
const isValid = await isStepValid(selectedTabIndex);
if (!isValid) return;
setSelectedTabIndex((prev) => prev + 1);
};
const isTabEnabled = async (index: number) => {
let isEnabled = true;
for (let i = index - 1; i >= 0; i -= 1) {
// eslint-disable-next-line no-await-in-loop
isEnabled = isEnabled && (await isStepValid(i));
}
return isEnabled;
};
const { control } = formMethods;
return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
<FormProvider {...formMethods}>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
{FORM_TABS.map((tab, index) => (
<Tab
onClick={async (e) => {
e.preventDefault();
const isEnabled = await isTabEnabled(index);
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
}}
className={({ selected }) =>
`w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
key={tab.key}
>
{index + 1}. {tab.name}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<SecretSyncSourceFields />
</Tab.Panel>
<Tab.Panel>
<SecretSyncDestinationFields />
</Tab.Panel>
<Tab.Panel>
<SecretSyncOptionsFields />
<Controller
control={control}
name="isEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={
value
? "Secrets will automatically be synced when changes occur in the source location"
: "Secrets will not be synced until enabled"
}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="bg-red/50 shadow-inner data-[state=checked]:bg-green/50"
id="secret-sync-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p className="w-14">{value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
);
}}
/>
</Tab.Panel>
<Tab.Panel>
<SecretSyncDetailsFields />
</Tab.Panel>
<Tab.Panel>
<SecretSyncReviewFields />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button onClick={handleNext} colorSchema="secondary">
{isFinalStep ? "Create Sync" : "Next"}
</Button>
{selectedTabIndex > 0 && (
<Button onClick={handlePrev} colorSchema="secondary">
Back
</Button>
)}
</div>
</form>
);
};
@@ -0,0 +1,103 @@
import { ReactNode } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
import { Button, ModalClose } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs";
import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas";
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields";
import { SecretSyncSourceFields } from "./SecretSyncSourceFields";
type Props = {
onComplete: (secretSync: TSecretSync) => void;
secretSync: TSecretSync;
fields: SecretSyncEditFields;
};
export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => {
const updateSecretSync = useUpdateSecretSync();
const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination];
const formMethods = useForm<TSecretSyncForm>({
resolver: zodResolver(SecretSyncFormSchema),
defaultValues: {
...secretSync,
secretPath: secretSync.folder.path,
description: secretSync.description ?? ""
},
reValidateMode: "onChange"
});
const onSubmit = async ({ environment, ...formData }: TSecretSyncForm) => {
try {
const updatedSecretSync = await updateSecretSync.mutateAsync({
syncId: secretSync.id,
...formData,
environment: environment.slug
});
createNotification({
text: `Successfully updated ${destinationName} Sync`,
type: "success"
});
onComplete(updatedSecretSync);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${destinationName} Sync`,
text: err.message,
type: "error"
});
}
};
let Component: ReactNode;
switch (fields) {
case SecretSyncEditFields.Destination:
Component = <SecretSyncDestinationFields />;
break;
case SecretSyncEditFields.Options:
Component = <SecretSyncOptionsFields hideInitialSync={Boolean(secretSync.lastSyncedAt)} />;
break;
case SecretSyncEditFields.Source:
Component = <SecretSyncSourceFields />;
break;
case SecretSyncEditFields.Details:
default:
Component = <SecretSyncDetailsFields />;
break;
}
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = formMethods;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...formMethods}>{Component}</FormProvider>
<div className="flex w-full justify-between gap-4 pt-4">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
isLoading={isSubmitting}
isDisabled={!isDirty || isSubmitting}
type="submit"
colorSchema="secondary"
>
Update Sync
</Button>
</div>
</form>
);
};
@@ -0,0 +1,89 @@
import { Controller, useFormContext } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SECRET_SYNC_CONNECTION_MAP } from "@app/helpers/secretSyncs";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { TSecretSyncForm } from "./schemas";
type Props = {
onChange?: VoidFunction;
};
export const SecretSyncConnectionField = ({ onChange: callback }: Props) => {
const { permission } = useOrgPermission();
const { control, watch } = useFormContext<TSecretSyncForm>();
const destination = watch("destination");
const app = SECRET_SYNC_CONNECTION_MAP[destination];
const { data: options, isLoading } = useListAvailableAppConnections(app);
const connectionName = APP_CONNECTION_MAP[app].name;
const canCreateConnection = permission.can(
OrgPermissionActions.Create,
OrgPermissionSubjects.AppConnections
);
const appName = APP_CONNECTION_MAP[SECRET_SYNC_CONNECTION_MAP[destination]].name;
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Specify the {appName} Connection to use to connect to {connectionName} and configure
destination parameters.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="App Connections can be created from the Organization Settings page."
isError={Boolean(error)}
errorText={error?.message}
label={`${connectionName} Connection`}
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
if (callback) callback();
}}
isLoading={isLoading}
options={options}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="connection"
/>
{options?.length === 0 && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? (
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link
to="/organization/settings"
className="underline"
search={{ selectedTab: "app-connections" }}
>
Organization Settings
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p>
)}
</>
);
};
@@ -0,0 +1,67 @@
import { Controller, useFormContext } from "react-hook-form";
import { components, OptionProps, SingleValue } from "react-select";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { Badge, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
const Option = ({ isSelected, children, ...props }: OptionProps<(typeof AWS_REGIONS)[number]>) => {
return (
<components.Option isSelected={isSelected} {...props}>
<div className="flex flex-row items-center justify-between">
<p className="truncate">{children}</p>
<Badge variant="success" className="ml-1 mr-auto cursor-pointer">
{props.data.slug}
</Badge>
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</div>
</components.Option>
);
};
export const AwsParameterStoreSyncFields = () => {
const { control } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
return (
<>
<SecretSyncConnectionField />
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Region">
<FilterableSelect
value={AWS_REGIONS.find((region) => region.slug === value)}
onChange={(option) =>
onChange((option as SingleValue<(typeof AWS_REGIONS)[number]>)?.slug)
}
options={AWS_REGIONS}
placeholder="Select region..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
components={{ Option }}
/>
</FormControl>
)}
control={control}
name="destinationConfig.region"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Path">
<Input value={value} onChange={onChange} placeholder="Path..." />
</FormControl>
)}
control={control}
name="destinationConfig.path"
/>
</>
);
};
@@ -0,0 +1,234 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { MultiValue, SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import {
TGitHubConnectionEnvironment,
TGitHubConnectionOrganization,
TGitHubConnectionRepository,
useGitHubConnectionListEnvironments,
useGitHubConnectionListOrganizations,
useGitHubConnectionListRepositories
} from "@app/hooks/api/appConnections/github";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync";
import { TSecretSyncForm } from "../schemas";
export const GitHubSyncFields = () => {
const {
control,
formState: { errors },
watch,
setValue
} = useFormContext<TSecretSyncForm & { destination: SecretSync.GitHub }>();
console.log("errors", errors, watch());
const connectionId = useWatch({ name: "connection.id", control });
const currentScope = watch("destinationConfig.scope");
const currentVisibility = watch("destinationConfig.visibility", GitHubSyncVisibility.All);
const currentOrg = watch("destinationConfig.org");
const currentRepo = watch("destinationConfig.repo");
const currentOwner = watch("destinationConfig.owner");
const { data: repositories = [], isPending: isRepositoriesPending } =
useGitHubConnectionListRepositories(connectionId, {
enabled: Boolean(connectionId)
});
const { data: organizations = [], isPending: isOrganizationsPending } =
useGitHubConnectionListOrganizations(connectionId, {
enabled: Boolean(connectionId && currentScope === GitHubSyncScope.Organization)
});
const { data: environments = [], isPending: isEnvironmentsPending } =
useGitHubConnectionListEnvironments(
{
connectionId,
repo: currentRepo,
owner: currentOwner
},
{
enabled: Boolean(
connectionId &&
currentRepo &&
currentOwner &&
currentScope === GitHubSyncScope.RepositoryEnvironment
)
}
);
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.org", "");
setValue("destinationConfig.repo", "");
setValue("destinationConfig.owner", "");
setValue("destinationConfig.selectedRepositoryIds", []);
}}
/>
<Controller
name="destinationConfig.scope"
control={control}
defaultValue={GitHubSyncScope.Repository}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Scope">
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select a scope..."
dropdownContainerClassName="max-w-none"
>
{Object.values(GitHubSyncScope).map((scope) => (
<SelectItem className="capitalize" value={scope} key={scope}>
{scope.replace("-", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{currentScope === GitHubSyncScope.Organization && (
<>
<Controller
name="destinationConfig.org"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Organization">
<FilterableSelect
isLoading={isOrganizationsPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={organizations.find((org) => org.login === value) ?? null}
onChange={(option) =>
onChange((option as SingleValue<TGitHubConnectionOrganization>)?.login ?? null)
}
options={organizations}
placeholder="Select an organization..."
getOptionLabel={(option) => option.login}
getOptionValue={(option) => option.login}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.visibility"
control={control}
defaultValue={GitHubSyncVisibility.All}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Visibility"
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select visibility..."
dropdownContainerClassName="max-w-none"
>
{Object.values(GitHubSyncVisibility).map((scope) => (
<SelectItem className="capitalize" value={scope} key={scope}>
{scope.replace("-", " ")} Repositories
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{currentVisibility === GitHubSyncVisibility.Selected && (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Selected Repositories"
>
<FilterableSelect
menuPlacement="top"
isLoading={isRepositoriesPending && Boolean(currentOrg)}
isDisabled={!currentOrg || !connectionId}
isMulti
value={repositories.filter((repo) => value?.includes(repo.id))}
onChange={(option) => {
const repos = option as MultiValue<TGitHubConnectionRepository>;
onChange(repos.map((repo) => repo.id));
}}
options={repositories.filter((repo) => repo.owner.login === currentOrg)}
placeholder="Select one or more repositories..."
getOptionLabel={(option) => `${option.owner.login}/${option.name}`}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
control={control}
name="destinationConfig.selectedRepositoryIds"
/>
)}
</>
)}
{currentScope !== GitHubSyncScope.Organization && (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Repository">
<FilterableSelect
menuPlacement="top"
isLoading={isRepositoriesPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={repositories.find((repo) => repo.name === value) ?? null}
onChange={(option) => {
const repo = option as SingleValue<TGitHubConnectionRepository>;
onChange(repo?.name);
setValue("destinationConfig.owner", repo?.owner.login ?? "");
}}
options={repositories}
placeholder="Select a repository..."
getOptionLabel={(option) => `${option.owner.login}/${option.name}`}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
control={control}
name="destinationConfig.repo"
/>
)}
{currentScope === GitHubSyncScope.RepositoryEnvironment && (
<Controller
name="destinationConfig.env"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Environment">
<FilterableSelect
menuPlacement="top"
isLoading={isEnvironmentsPending && Boolean(connectionId) && Boolean(currentRepo)}
isDisabled={!connectionId || !currentRepo}
value={environments.find((env) => env.name === value) ?? null}
onChange={(option) =>
onChange((option as SingleValue<TGitHubConnectionEnvironment>)?.name ?? null)
}
options={environments}
placeholder="Select an environment..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
)}
</>
);
};
@@ -0,0 +1,22 @@
import { useFormContext } from "react-hook-form";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields";
import { GitHubSyncFields } from "./GitHubSyncFields";
export const SecretSyncDestinationFields = () => {
const { watch } = useFormContext<TSecretSyncForm>();
const destination = watch("destination");
switch (destination) {
case SecretSync.AWSParameterStore:
return <AwsParameterStoreSyncFields />;
case SecretSync.GitHub:
return <GitHubSyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
};
@@ -0,0 +1 @@
export * from "./SecretSyncDestinationFields";
@@ -0,0 +1,51 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { TSecretSyncForm } from "./schemas";
export const SecretSyncDetailsFields = () => {
const { control } = useFormContext<TSecretSyncForm>();
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Provide a name and description for this Secret Sync.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
helperText="Must be slug-friendly"
isError={Boolean(error)}
errorText={error?.message}
label="Name"
>
<Input value={value} onChange={onChange} placeholder="my-secret-sync" />
</FormControl>
)}
control={control}
name="name"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Description"
>
<TextArea
value={value}
onChange={onChange}
placeholder="Describe the purpose of this sync..."
className="!resize-none"
rows={4}
/>
</FormControl>
)}
control={control}
name="description"
/>
</>
);
};
@@ -0,0 +1,124 @@
import { Controller, useFormContext } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { useSecretSyncOption } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "./schemas";
type Props = {
hideInitialSync?: boolean;
};
export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
const { control, watch } = useFormContext<TSecretSyncForm>();
const destination = watch("destination");
const destinationName = SECRET_SYNC_MAP[destination].name;
const { syncOption } = useSecretSyncOption(destination);
return (
<>
<p className="mb-4 text-sm text-bunker-300">Configure how secrets should be synced.</p>
{!hideInitialSync && (
<>
<Controller
name="syncOptions.initialSyncBehavior"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipClassName="max-w-lg py-3"
tooltipText={
syncOption?.canImportSecrets ? (
<div className="flex flex-col gap-3">
<p>
Specify how Infisical should resolve the initial sync to {destinationName}.
The following options are available:
</p>
<ul className="flex list-disc flex-col gap-3 pl-4">
{Object.values(SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP).map((details) => {
const { name, description } = details(destinationName);
return (
<li key={name}>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">{name}</span>:{" "}
{description}
</p>
</li>
);
})}
</ul>
</div>
) : undefined
}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Initial Sync Behavior"
>
<Select
isDisabled={!syncOption?.canImportSecrets}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
placeholder="Select an option..."
dropdownContainerClassName="max-w-none"
>
{Object.entries(SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP).map(([key, details]) => {
const { name } = details(destinationName);
return (
<SelectItem value={key} key={key}>
{name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{!syncOption?.canImportSecrets && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faTriangleExclamation} />
{destinationName} only supports overwriting destination secrets. Secrets not present
in Infisical will be removed from the destination.
</p>
)}
</>
)}
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Prepend Prefix"
>
<Input className="uppercase" value={value} onChange={onChange} placeholder="INF_" />
</FormControl>
)}
control={control}
name="syncOptions.prependPrefix"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Append Suffix"
>
<Input className="uppercase" value={value} onChange={onChange} placeholder="_INF" />
</FormControl>
)}
control={control}
name="syncOptions.appendSuffix"
/>
</>
);
};
@@ -0,0 +1,29 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AwsParameterStoreSyncReviewFields = () => {
const { watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
const [region, path] = watch(["destinationConfig.region", "destinationConfig.path"]);
const awsRegion = AWS_REGIONS.find((r) => r.slug === region);
return (
<>
<SecretSyncLabel label="Region">
{awsRegion?.name}
<Badge className="ml-1" variant="success">
{awsRegion?.slug}{" "}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Path">{path}</SecretSyncLabel>
</>
);
};
@@ -0,0 +1,61 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
export const GitHubSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.GitHub }>();
const config = watch("destinationConfig");
let ScopeComponents: ReactNode;
switch (config.scope) {
case GitHubSyncScope.Repository:
ScopeComponents = (
<SecretSyncLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
);
break;
case GitHubSyncScope.Organization:
ScopeComponents = (
<>
<SecretSyncLabel label="Organization">{config.org}</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Visibility">
{config.visibility}
</SecretSyncLabel>
</>
);
break;
case GitHubSyncScope.RepositoryEnvironment:
ScopeComponents = (
<>
<SecretSyncLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Environment">
{config.env}
</SecretSyncLabel>
</>
);
break;
default:
throw new Error(
`Unhandled GitHub Sync Review Field Scope ${(config as TGitHubSync["destinationConfig"]).scope}`
);
}
return (
<>
<SecretSyncLabel className="capitalize" label="Scope">
{config.scope.replace("-", " ")}
</SecretSyncLabel>
{ScopeComponents}
</>
);
};
@@ -0,0 +1,90 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
export const SecretSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm>();
let DestinationFieldsComponent: ReactNode;
const {
name,
description,
connection,
environment,
secretPath,
syncOptions: { appendSuffix, prependPrefix, initialSyncBehavior },
destination,
isEnabled
} = watch();
const destinationName = SECRET_SYNC_MAP[destination].name;
switch (destination) {
case SecretSync.AWSParameterStore:
DestinationFieldsComponent = <AwsParameterStoreSyncReviewFields />;
break;
case SecretSync.GitHub:
DestinationFieldsComponent = <GitHubSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
return (
<div className="mb-4 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Source</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Environment">{environment.name}</SecretSyncLabel>
<SecretSyncLabel label="Secret Path">{secretPath}</SecretSyncLabel>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Destination</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Connection">{connection.name}</SecretSyncLabel>
{DestinationFieldsComponent}
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Options</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Sync Enabled">
<Badge variant={isEnabled ? "success" : "danger"}>
{isEnabled ? "Enabled" : "Disabled"}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destinationName).name}
</SecretSyncLabel>
<SecretSyncLabel label="Prepend Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Append Suffix">{appendSuffix}</SecretSyncLabel>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Name">{name}</SecretSyncLabel>
<SecretSyncLabel label="Description">{description}</SecretSyncLabel>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretSyncReviewFields";
@@ -0,0 +1,55 @@
import { Controller, useFormContext } from "react-hook-form";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { TSecretSyncForm } from "./schemas";
export const SecretSyncSourceFields = () => {
const { control, watch } = useFormContext<TSecretSyncForm>();
const { currentWorkspace } = useWorkspace();
const selectedEnvironment = watch("environment");
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Specify the environment and path where you would like to sync secrets from.
</p>
<Controller
defaultValue={currentWorkspace.environments[0]}
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Environment" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
value={value}
onChange={onChange}
options={currentWorkspace.environments}
placeholder="Select environment..."
getOptionLabel={(option) => option?.name}
getOptionValue={(option) => option?.id}
/>
</FormControl>
)}
/>
<Controller
defaultValue="/"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Secret Path">
<SecretPathInput
environment={selectedEnvironment?.slug}
value={value}
onChange={onChange}
/>
</FormControl>
)}
control={control}
name="secretPath"
/>
</>
);
};
@@ -0,0 +1,2 @@
export * from "./CreateSecretSyncForm";
export * from "./EditSecretSyncForm";
@@ -0,0 +1,28 @@
import { z } from "zod";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AwsParameterStoreSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: z.object({
path: z
.string()
.min(1, "Parameter Store Path required")
.superRefine((val, ctx) => {
if (!/^\/([/]|(([\w-]+\/)+))?$/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid path - must follow "/example/path/" format'
});
}
if (val.length > 2048) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Cannot exceed 2048 characters"
});
}
}),
region: z.string().min(1, "Region required")
})
});
@@ -0,0 +1,45 @@
import { z } from "zod";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync";
export const GitHubSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.GitHub),
destinationConfig: z
.discriminatedUnion("scope", [
z.object({
scope: z.literal(GitHubSyncScope.Organization),
org: z.string().min(1, "Organization name required"),
visibility: z.nativeEnum(GitHubSyncVisibility),
selectedRepositoryIds: z.number().array().optional()
}),
z.object({
scope: z.literal(GitHubSyncScope.Repository),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required")
}),
z.object({
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required"),
env: z.string().min(1, "Environment name required")
})
])
.superRefine((options, ctx) => {
if (options.scope !== GitHubSyncScope.Organization) return;
if (
options.visibility === GitHubSyncVisibility.Selected &&
!options.selectedRepositoryIds?.length
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Select at least 1 repository",
path: ["selectedRepositoryIds"]
});
}
})
});
@@ -0,0 +1 @@
export * from "./secret-sync-schema";
@@ -0,0 +1,39 @@
import { z } from "zod";
import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema";
import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs";
import { slugSchema } from "@app/lib/schemas";
import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema";
const BaseSecretSyncSchema = z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(),
connection: z.object({ name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
secretPath: z.string().min(1, "Secret path required"),
syncOptions: z.object({
initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior),
prependPrefix: z
.string()
.trim()
.transform((str) => str.toUpperCase())
.optional(),
appendSuffix: z
.string()
.trim()
.transform((str) => str.toUpperCase())
.optional()
}),
isEnabled: z.boolean()
});
// TODO: union once more supported
export const SecretSyncFormSchema = z
.discriminatedUnion("destination", [
AwsParameterStoreSyncDestinationSchema,
GitHubSyncDestinationSchema
])
.and(BaseSecretSyncSchema);
export type TSecretSyncForm = z.infer<typeof SecretSyncFormSchema>;
@@ -0,0 +1,41 @@
import { twMerge } from "tailwind-merge";
import { useGitHubConnectionListRepositories } from "@app/hooks/api/appConnections/github";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
type Props = {
secretSync: TGitHubSync;
};
export const GitHubSyncSelectedRepositoriesTooltipContent = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
const showRepositories =
destinationConfig.scope === GitHubSyncScope.Organization &&
Boolean(destinationConfig.selectedRepositoryIds?.length);
const { data: repositories, isPending } = useGitHubConnectionListRepositories(
secretSync.connectionId,
{
enabled: showRepositories
}
);
if (destinationConfig.scope === GitHubSyncScope.Organization) {
return (
<>
<span className="text-xs text-bunker-300">Repositories:</span>
<p className={twMerge("text-sm", isPending && "text-mineshaft-400")}>
{isPending
? "Loading..."
: repositories
?.filter((repo) => destinationConfig?.selectedRepositoryIds?.includes(repo.id))
.map((repo) => repo.name)
.join(", ")}
</p>
</>
);
}
return null;
};
@@ -0,0 +1,9 @@
export * from "./CreateSecretSyncModal";
export * from "./DeleteSecretSyncModal";
export * from "./EditSecretSyncModal";
export * from "./SecretSyncImportSecretsModal";
export * from "./SecretSyncImportStatusBadge";
export * from "./SecretSyncLabel";
export * from "./SecretSyncRemoveSecretsModal";
export * from "./SecretSyncRemoveStatusBadge";
export * from "./SecretSyncStatusBadge";
@@ -0,0 +1,6 @@
export enum SecretSyncEditFields {
Details = "details",
Options = "options",
Source = "source",
Destination = "destination"
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge";
import { Card, CardBody, CardFooter, CardTitle } from "../Card";
import { IconButton } from "../IconButton";
export type ModalContentProps = DialogPrimitive.DialogContentProps & {
export type ModalContentProps = Omit<DialogPrimitive.DialogContentProps, "title"> & {
title?: ReactNode;
subTitle?: ReactNode;
footerContent?: ReactNode;
+8 -1
View File
@@ -9,6 +9,7 @@ export type SwitchProps = Omit<SwitchPrimitive.SwitchProps, "checked" | "disable
isRequired?: boolean;
isDisabled?: boolean;
containerClassName?: string;
thumbClassName?: string;
};
export const Switch = ({
@@ -19,6 +20,7 @@ export const Switch = ({
isDisabled,
isRequired,
containerClassName,
thumbClassName,
...props
}: SwitchProps): JSX.Element => (
<div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
@@ -38,7 +40,12 @@ export const Switch = ({
)}
id={id}
>
<SwitchPrimitive.Thumb className="block h-4 w-4 translate-x-0.5 rounded-full border-none bg-black shadow transition-all will-change-transform data-[state=checked]:translate-x-[18px]" />
<SwitchPrimitive.Thumb
className={twMerge(
"block h-4 w-4 translate-x-0.5 rounded-full border-none bg-black shadow transition-all will-change-transform data-[state=checked]:translate-x-[18px]",
thumbClassName
)}
/>
</SwitchPrimitive.Root>
</div>
);
+8
View File
@@ -64,10 +64,18 @@ export const ROUTE_PATHS = Object.freeze({
"/secret-manager/$projectId/overview",
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/overview"
),
IntegrationsListPage: setRoute(
"/secret-manager/$projectId/integrations",
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/integrations/"
),
IntegrationDetailsByIDPage: setRoute(
"/secret-manager/$projectId/integrations/$integrationId",
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/integrations/$integrationId"
),
SecretSyncDetailsByIDPage: setRoute(
"/secret-manager/$projectId/integrations/secret-syncs/$destination/$syncId",
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId"
),
Integratons: {
SelectIntegrationAuth: setRoute(
"/secret-manager/$projectId/integrations/select-integration-auth",
@@ -31,6 +31,18 @@ export enum OrgPermissionAdminConsoleAction {
AccessAllProjects = "access-all-projects"
}
export enum OrgPermissionAppConnectionActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
Connect = "connect"
}
export type AppConnectionSubjectFields = {
connectionId: string;
};
export type OrgPermissionSet =
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
| [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace]
@@ -50,5 +62,12 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
| [OrgPermissionActions, OrgPermissionSubjects.AppConnections];
// | [
// OrgPermissionAppConnectionActions,
// (
// | OrgPermissionSubjects.AppConnections
// | (ForcedSubject<OrgPermissionSubjects.AppConnections> & AppConnectionSubjectFields)
// )
// ];
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
@@ -91,7 +91,8 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
Cmek = "cmek"
Cmek = "cmek",
SecretSyncs = "secret-syncs"
}
export type SecretSubjectFields = {
@@ -173,6 +174,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions, ProjectPermissionSub.SecretSyncs]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
+32
View File
@@ -27,3 +27,35 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
throw new Error(`Unhandled App Connection Method: ${method}`);
}
};
export const AWS_REGIONS = [
{ name: "US East (Ohio)", slug: "us-east-2" },
{ name: "US East (N. Virginia)", slug: "us-east-1" },
{ name: "US West (N. California)", slug: "us-west-1" },
{ name: "US West (Oregon)", slug: "us-west-2" },
{ name: "Africa (Cape Town)", slug: "af-south-1" },
{ name: "Asia Pacific (Hong Kong)", slug: "ap-east-1" },
{ name: "Asia Pacific (Hyderabad)", slug: "ap-south-2" },
{ name: "Asia Pacific (Jakarta)", slug: "ap-southeast-3" },
{ name: "Asia Pacific (Melbourne)", slug: "ap-southeast-4" },
{ name: "Asia Pacific (Mumbai)", slug: "ap-south-1" },
{ name: "Asia Pacific (Osaka)", slug: "ap-northeast-3" },
{ name: "Asia Pacific (Seoul)", slug: "ap-northeast-2" },
{ name: "Asia Pacific (Singapore)", slug: "ap-southeast-1" },
{ name: "Asia Pacific (Sydney)", slug: "ap-southeast-2" },
{ name: "Asia Pacific (Tokyo)", slug: "ap-northeast-1" },
{ name: "Canada (Central)", slug: "ca-central-1" },
{ name: "Europe (Frankfurt)", slug: "eu-central-1" },
{ name: "Europe (Ireland)", slug: "eu-west-1" },
{ name: "Europe (London)", slug: "eu-west-2" },
{ name: "Europe (Milan)", slug: "eu-south-1" },
{ name: "Europe (Paris)", slug: "eu-west-3" },
{ name: "Europe (Spain)", slug: "eu-south-2" },
{ name: "Europe (Stockholm)", slug: "eu-north-1" },
{ name: "Europe (Zurich)", slug: "eu-central-2" },
{ name: "Middle East (Bahrain)", slug: "me-south-1" },
{ name: "Middle East (UAE)", slug: "me-central-1" },
{ name: "South America (Sao Paulo)", slug: "sa-east-1" },
{ name: "AWS GovCloud (US-East)", slug: "us-gov-east-1" },
{ name: "AWS GovCloud (US-West)", slug: "us-gov-west-1" }
];
+48
View File
@@ -0,0 +1,48 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
SecretSync,
SecretSyncImportBehavior,
SecretSyncInitialSyncBehavior
} from "@app/hooks/api/secretSyncs";
export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }> = {
[SecretSync.AWSParameterStore]: { name: "Parameter Store", image: "Amazon Web Services.png" },
[SecretSync.GitHub]: { name: "GitHub", image: "GitHub.png" }
};
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.AWSParameterStore]: AppConnection.AWS,
[SecretSync.GitHub]: AppConnection.GitHub
};
export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record<
SecretSyncInitialSyncBehavior,
(destinationName: string) => { name: string; description: string }
> = {
[SecretSyncInitialSyncBehavior.OverwriteDestination]: (destinationName: string) => ({
name: "Overwrite Destination Secrets",
description: `Infisical will overwrite any secrets located in the ${destinationName} destination, removing any secrets that are not present within Infiscal. `
}),
[SecretSyncInitialSyncBehavior.ImportPrioritizeSource]: (destinationName: string) => ({
name: "Import Destination Secrets - Prioritize Infisical Values",
description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in Infisical over ${destinationName}.`
}),
[SecretSyncInitialSyncBehavior.ImportPrioritizeDestination]: (destinationName: string) => ({
name: `Import Destination Secrets - Prioritize ${destinationName} Values`,
description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in ${destinationName} over Infisical.`
})
};
export const SECRET_SYNC_IMPORT_BEHAVIOR_MAP: Record<
SecretSyncImportBehavior,
(destinationName: string) => { name: string; description: string }
> = {
[SecretSyncImportBehavior.PrioritizeSource]: (destinationName: string) => ({
name: "Prioritize Infisical Values",
description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in Infisical over ${destinationName}.`
}),
[SecretSyncImportBehavior.PrioritizeDestination]: (destinationName: string) => ({
name: `Prioritize ${destinationName} Values`,
description: `Infisical will import any secrets present in the ${destinationName} destination prior to syncing, prioritizing values present in ${destinationName} over Infisical.`
})
};
@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";
@@ -0,0 +1,105 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "@app/hooks/api/appConnections";
import {
TGitHubConnectionEnvironment,
TGitHubConnectionListEnvironmentsResponse,
TGitHubConnectionListOrganizationsResponse,
TGitHubConnectionListRepositoriesResponse,
TGitHubConnectionOrganization,
TGitHubConnectionRepository,
TListGitHubConnectionEnvironments
} from "./types";
const githubConnectionKeys = {
all: [...appConnectionKeys.all, "github"] as const,
listRepositories: (connectionId: string) =>
[...githubConnectionKeys.all, "repositories", connectionId] as const,
listOrganizations: (connectionId: string) =>
[...githubConnectionKeys.all, "organizations", connectionId] as const,
listEnvironments: ({ connectionId, repo, owner }: TListGitHubConnectionEnvironments) =>
[...githubConnectionKeys.all, "environments", connectionId, repo, owner] as const
};
export const useGitHubConnectionListRepositories = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TGitHubConnectionRepository[],
unknown,
TGitHubConnectionRepository[],
ReturnType<typeof githubConnectionKeys.listRepositories>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: githubConnectionKeys.listRepositories(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TGitHubConnectionListRepositoriesResponse>(
`/api/v1/app-connections/github/${connectionId}/repositories`
);
return data.repositories;
},
...options
});
};
export const useGitHubConnectionListOrganizations = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TGitHubConnectionOrganization[],
unknown,
TGitHubConnectionOrganization[],
ReturnType<typeof githubConnectionKeys.listOrganizations>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: githubConnectionKeys.listOrganizations(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TGitHubConnectionListOrganizationsResponse>(
`/api/v1/app-connections/github/${connectionId}/organizations`
);
return data.organizations;
},
...options
});
};
export const useGitHubConnectionListEnvironments = (
{ connectionId, repo, owner }: TListGitHubConnectionEnvironments,
options?: Omit<
UseQueryOptions<
TGitHubConnectionEnvironment[],
unknown,
TGitHubConnectionEnvironment[],
ReturnType<typeof githubConnectionKeys.listEnvironments>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: githubConnectionKeys.listEnvironments({ connectionId, repo, owner }),
queryFn: async () => {
const { data } = await apiRequest.get<TGitHubConnectionListEnvironmentsResponse>(
`/api/v1/app-connections/github/${connectionId}/environments`,
{
params: {
repo,
owner
}
}
);
return data.environments;
},
...options
});
};
@@ -0,0 +1,33 @@
export type TGitHubConnectionOrganization = {
login: string;
id: number;
};
export type TGitHubConnectionRepository = {
id: number;
name: string;
owner: TGitHubConnectionOrganization;
};
export type TGitHubConnectionEnvironment = {
id: number;
name: string;
};
export type TGitHubConnectionListRepositoriesResponse = {
repositories: TGitHubConnectionRepository[];
};
export type TGitHubConnectionListOrganizationsResponse = {
organizations: TGitHubConnectionOrganization[];
};
export type TGitHubConnectionListEnvironmentsResponse = {
environments: TGitHubConnectionEnvironment[];
};
export type TListGitHubConnectionEnvironments = {
connectionId: string;
repo: string;
owner: string;
};
@@ -7,6 +7,8 @@ import {
TAppConnection,
TAppConnectionMap,
TAppConnectionOptions,
TAvailableAppConnection,
TAvailableAppConnectionsResponse,
TGetAppConnection,
TListAppConnections
} from "@app/hooks/api/appConnections/types";
@@ -19,9 +21,10 @@ export const appConnectionKeys = {
all: ["app-connection"] as const,
options: () => [...appConnectionKeys.all, "options"] as const,
list: () => [...appConnectionKeys.all, "list"] as const,
listAvailable: (app: AppConnection) => [...appConnectionKeys.all, app, "list-available"] as const,
listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app],
byId: (app: AppConnection, templateId: string) =>
[...appConnectionKeys.all, app, "by-id", templateId] as const
byId: (app: AppConnection, connectionId: string) =>
[...appConnectionKeys.all, app, "by-id", connectionId] as const
};
export const useAppConnectionOptions = (
@@ -83,6 +86,31 @@ export const useListAppConnections = (
});
};
export const useListAvailableAppConnections = (
app: AppConnection,
options?: Omit<
UseQueryOptions<
TAvailableAppConnection[],
unknown,
TAvailableAppConnection[],
ReturnType<typeof appConnectionKeys.listAvailable>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: appConnectionKeys.listAvailable(app),
queryFn: async () => {
const { data } = await apiRequest.get<TAvailableAppConnectionsResponse>(
`/api/v1/app-connections/${app}/available`
);
return data.appConnections;
},
...options
});
};
export const useListAppConnectionsByApp = <T extends AppConnection>(
app: T,
options?: Omit<
@@ -8,10 +8,13 @@ export * from "./github-connection";
export type TAppConnection = TAwsConnection | TGitHubConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "app" | "id">;
export type TListAppConnections<T extends TAppConnection> = { appConnections: T[] };
export type TGetAppConnection<T extends TAppConnection> = { appConnection: T };
export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] };
export type TAppConnectionResponse = { appConnection: TAppConnection };
export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppConnection[] };
export type TCreateAppConnectionDTO = Pick<
TAppConnection,
+30 -1
View File
@@ -85,7 +85,36 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.INTEGRATION_SYNCED]: "Integration sync",
[EventType.CREATE_SHARED_SECRET]: "Create shared secret",
[EventType.DELETE_SHARED_SECRET]: "Delete shared secret",
[EventType.READ_SHARED_SECRET]: "Read shared secret"
[EventType.READ_SHARED_SECRET]: "Read shared secret",
[EventType.CREATE_CMEK]: "Create KMS key",
[EventType.UPDATE_CMEK]: "Update KMS key",
[EventType.DELETE_CMEK]: "Delete KMS key",
[EventType.GET_CMEKS]: "List KMS keys",
[EventType.CMEK_ENCRYPT]: "Encrypt with KMS key",
[EventType.CMEK_DECRYPT]: "Decrypt with KMS key",
[EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]:
"Update SSO group to organization role mapping",
[EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping",
[EventType.GET_PROJECT_TEMPLATES]: "List project templates",
[EventType.GET_PROJECT_TEMPLATE]: "Get project template",
[EventType.CREATE_PROJECT_TEMPLATE]: "Create project template",
[EventType.UPDATE_PROJECT_TEMPLATE]: "Update project template",
[EventType.DELETE_PROJECT_TEMPLATE]: "Delete project template",
[EventType.APPLY_PROJECT_TEMPLATE]: "Apply project template",
[EventType.GET_APP_CONNECTIONS]: "List App Connections",
[EventType.GET_AVAILABLE_APP_CONNECTIONS_DETAILS]: "List App Connections Details",
[EventType.GET_APP_CONNECTION]: "Get App Connection",
[EventType.CREATE_APP_CONNECTION]: "Create App Connection",
[EventType.UPDATE_APP_CONNECTION]: "Update App Connection",
[EventType.DELETE_APP_CONNECTION]: "Delete App Connection",
[EventType.GET_SECRET_SYNCS]: "List Secret Syncs",
[EventType.GET_SECRET_SYNC]: "Get Secret Sync",
[EventType.CREATE_SECRET_SYNC]: "Create Secret Sync",
[EventType.UPDATE_SECRET_SYNC]: "Update Secret Sync",
[EventType.DELETE_SECRET_SYNC]: "Delete Secret Sync",
[EventType.SECRET_SYNC_SYNC_SECRETS]: "Secret Sync synced secrets",
[EventType.SECRET_SYNC_IMPORT_SECRETS]: "Secret Sync imported secrets",
[EventType.SECRET_SYNC_REMOVE_SECRETS]: "Secret Sync removed secrets"
};
export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = {
+29 -1
View File
@@ -99,5 +99,33 @@ export enum EventType {
INTEGRATION_SYNCED = "integration-synced",
CREATE_SHARED_SECRET = "create-shared-secret",
DELETE_SHARED_SECRET = "delete-shared-secret",
READ_SHARED_SECRET = "read-shared-secret"
READ_SHARED_SECRET = "read-shared-secret",
CREATE_CMEK = "create-cmek",
UPDATE_CMEK = "update-cmek",
DELETE_CMEK = "delete-cmek",
GET_CMEKS = "get-cmeks",
CMEK_ENCRYPT = "cmek-encrypt",
CMEK_DECRYPT = "cmek-decrypt",
UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping",
GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping",
GET_PROJECT_TEMPLATES = "get-project-templates",
GET_PROJECT_TEMPLATE = "get-project-template",
CREATE_PROJECT_TEMPLATE = "create-project-template",
UPDATE_PROJECT_TEMPLATE = "update-project-template",
DELETE_PROJECT_TEMPLATE = "delete-project-template",
APPLY_PROJECT_TEMPLATE = "apply-project-template",
GET_APP_CONNECTIONS = "get-app-connections",
GET_AVAILABLE_APP_CONNECTIONS_DETAILS = "get-available-app-connections-details",
GET_APP_CONNECTION = "get-app-connection",
CREATE_APP_CONNECTION = "create-app-connection",
UPDATE_APP_CONNECTION = "update-app-connection",
DELETE_APP_CONNECTION = "delete-app-connection",
GET_SECRET_SYNCS = "get-secret-syncs",
GET_SECRET_SYNC = "get-secret-sync",
CREATE_SECRET_SYNC = "create-secret-sync",
UPDATE_SECRET_SYNC = "update-secret-sync",
DELETE_SECRET_SYNC = "delete-secret-sync",
SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets",
SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets",
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets"
}
+1 -1
View File
@@ -182,7 +182,7 @@ export const queryClient = new QueryClient({
createNotification({
title: "Bad Request",
type: "error",
text: `${serverResponse.message}.`,
text: `${serverResponse.message}${serverResponse.message.endsWith(".") ? "" : "."}`,
copyActions: [
{
value: serverResponse.reqId,
@@ -0,0 +1,22 @@
export enum SecretSync {
AWSParameterStore = "aws-parameter-store",
GitHub = "github"
}
export enum SecretSyncStatus {
Pending = "pending",
Running = "running",
Succeeded = "succeeded",
Failed = "failed"
}
export enum SecretSyncInitialSyncBehavior {
OverwriteDestination = "overwrite-destination",
ImportPrioritizeSource = "import-prioritize-source",
ImportPrioritizeDestination = "import-prioritize-destination"
}
export enum SecretSyncImportBehavior {
PrioritizeSource = "prioritize-source",
PrioritizeDestination = "prioritize-destination"
}
@@ -0,0 +1,4 @@
export * from "./enums";
export * from "./mutations";
export * from "./queries";
export * from "./types";
@@ -0,0 +1,116 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { secretSyncKeys } from "@app/hooks/api/secretSyncs/queries";
import {
TCreateSecretSyncDTO,
TDeleteSecretSyncDTO,
TSecretSyncResponse,
TTriggerSecretSyncImportSecretsDTO,
TTriggerSecretSyncRemoveSecretsDTO,
TTriggerSecretSyncSyncSecretsDTO,
TUpdateSecretSyncDTO
} from "@app/hooks/api/secretSyncs/types";
export const useCreateSecretSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ destination, ...params }: TCreateSecretSyncDTO) => {
const { data } = await apiRequest.post<TSecretSyncResponse>(
`/api/v1/secret-syncs/${destination}`,
params
);
return data.secretSync;
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() })
});
};
export const useUpdateSecretSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ syncId, destination, ...params }: TUpdateSecretSyncDTO) => {
const { data } = await apiRequest.patch<TSecretSyncResponse>(
`/api/v1/secret-syncs/${destination}/${syncId}`,
params
);
return data.secretSync;
},
onSuccess: (_, { syncId, destination }) => {
queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() });
queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) });
}
});
};
export const useDeleteSecretSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ syncId, destination }: TDeleteSecretSyncDTO) => {
const { data } = await apiRequest.delete(`/api/v1/secret-syncs/${destination}/${syncId}`);
return data;
},
onSuccess: (_, { syncId, destination }) => {
queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() });
queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) });
}
});
};
export const useTriggerSecretSyncSyncSecrets = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ syncId, destination }: TTriggerSecretSyncSyncSecretsDTO) => {
const { data } = await apiRequest.post(
`/api/v1/secret-syncs/${destination}/${syncId}/sync-secrets`
);
return data;
},
onSuccess: (_, { syncId, destination }) => {
queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() });
queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) });
}
});
};
export const useTriggerSecretSyncImportSecrets = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
syncId,
destination,
importBehavior
}: TTriggerSecretSyncImportSecretsDTO) => {
const { data } = await apiRequest.post(
`/api/v1/secret-syncs/${destination}/${syncId}/import-secrets?importBehavior=${importBehavior}`
);
return data;
},
onSuccess: (_, { syncId, destination }) => {
queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() });
queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) });
}
});
};
export const useTriggerSecretSyncRemoveSecrets = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ syncId, destination }: TTriggerSecretSyncRemoveSecretsDTO) => {
const { data } = await apiRequest.post(
`/api/v1/secret-syncs/${destination}/${syncId}/remove-secrets`
);
return data;
},
onSuccess: (_, { syncId, destination }) => {
queryClient.invalidateQueries({ queryKey: secretSyncKeys.list() });
queryClient.invalidateQueries({ queryKey: secretSyncKeys.byId(destination, syncId) });
}
});
};
@@ -0,0 +1,88 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { SecretSync, TSecretSyncOption } from "@app/hooks/api/secretSyncs";
import {
TListSecretSyncOptions,
TListSecretSyncs,
TSecretSync,
TSecretSyncResponse
} from "@app/hooks/api/secretSyncs/types";
export const secretSyncKeys = {
all: ["secret-sync"] as const,
options: () => [...secretSyncKeys.all, "options"] as const,
list: () => [...secretSyncKeys.all, "list"] as const,
byId: (destination: SecretSync, syncId: string) =>
[...secretSyncKeys.all, destination, "by-id", syncId] as const
};
export const useSecretSyncOptions = (
options?: Omit<
UseQueryOptions<
TSecretSyncOption[],
unknown,
TSecretSyncOption[],
ReturnType<typeof secretSyncKeys.options>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: secretSyncKeys.options(),
queryFn: async () => {
const { data } = await apiRequest.get<TListSecretSyncOptions>("/api/v1/secret-syncs/options");
return data.secretSyncOptions;
},
...options
});
};
export const useSecretSyncOption = (destination: SecretSync) => {
const { data: syncOptions, isPending } = useSecretSyncOptions();
const syncOption = syncOptions?.find((option) => option.destination === destination);
return { syncOption, isPending };
};
export const useListSecretSyncs = (
projectId: string,
options?: Omit<
UseQueryOptions<TSecretSync[], unknown, TSecretSync[], ReturnType<typeof secretSyncKeys.list>>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: secretSyncKeys.list(),
queryFn: async () => {
const { data } = await apiRequest.get<TListSecretSyncs>("/api/v1/secret-syncs", {
params: { projectId }
});
return data.secretSyncs;
},
...options
});
};
export const useGetSecretSync = (
destination: SecretSync,
syncId: string,
options?: Omit<
UseQueryOptions<TSecretSync, unknown, TSecretSync, ReturnType<typeof secretSyncKeys.byId>>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: secretSyncKeys.byId(destination, syncId),
queryFn: async () => {
const { data } = await apiRequest.get<TSecretSyncResponse>(
`/api/v1/secret-syncs/${destination}/${syncId}`
);
return data.secretSync;
},
...options
});
};
@@ -0,0 +1,16 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TAwsParameterStoreSync = TRootSecretSync & {
destination: SecretSync.AWSParameterStore;
destinationConfig: {
path: string;
region: string;
};
connection: {
app: AppConnection.AWS;
name: string;
id: string;
};
};
@@ -0,0 +1,42 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export enum GitHubSyncScope {
Organization = "organization",
Repository = "repository",
RepositoryEnvironment = "repository-environment"
}
export enum GitHubSyncVisibility {
All = "all",
Private = "private",
Selected = "selected"
}
export type TGitHubSync = TRootSecretSync & {
destination: SecretSync.GitHub;
destinationConfig:
| {
scope: GitHubSyncScope.Organization;
org: string;
visibility: GitHubSyncVisibility;
selectedRepositoryIds?: number[];
}
| {
scope: GitHubSyncScope.Repository;
owner: string;
repo: string;
}
| {
scope: GitHubSyncScope.RepositoryEnvironment;
owner: string;
repo: string;
env: string;
};
connection: {
app: AppConnection.GitHub;
name: string;
id: string;
};
};
@@ -0,0 +1,56 @@
import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs";
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
import { TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
import { DiscriminativePick } from "@app/types";
export type TSecretSyncOption = {
name: string;
destination: SecretSync;
canImportSecrets: boolean;
};
export type TSecretSync = TAwsParameterStoreSync | TGitHubSync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };
export type TListSecretSyncOptions = { secretSyncOptions: TSecretSyncOption[] };
export type TSecretSyncResponse = { secretSync: TSecretSync };
export type TCreateSecretSyncDTO = DiscriminativePick<
TSecretSync,
| "name"
| "destinationConfig"
| "description"
| "connectionId"
| "syncOptions"
| "destination"
| "isEnabled"
> & { environment: string; secretPath: string; projectId: string };
export type TUpdateSecretSyncDTO = Partial<
Omit<TCreateSecretSyncDTO, "connectionId" | "destination" | "projectId">
> & {
destination: SecretSync;
syncId: string;
};
export type TDeleteSecretSyncDTO = {
destination: SecretSync;
syncId: string;
};
export type TTriggerSecretSyncSyncSecretsDTO = {
destination: SecretSync;
syncId: string;
};
export type TTriggerSecretSyncImportSecretsDTO = {
destination: SecretSync;
syncId: string;
importBehavior: SecretSyncImportBehavior;
};
export type TTriggerSecretSyncRemoveSecretsDTO = {
destination: SecretSync;
syncId: string;
};
@@ -0,0 +1,46 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSyncInitialSyncBehavior, SecretSyncStatus } from "@app/hooks/api/secretSyncs";
export type TRootSecretSync = {
id: string;
name: string;
description?: string | null;
version: number;
folderId: string;
connectionId: string;
createdAt: string;
updatedAt: string;
isEnabled: boolean;
projectId: string;
syncStatus: SecretSyncStatus | null;
lastSyncJobId: string | null;
lastSyncedAt: Date | null;
lastSyncMessage: string | null;
importStatus: SecretSyncStatus | null;
lastImportJobId: string | null;
lastImportedAt: Date | null;
lastImportMessage: string | null;
removeStatus: SecretSyncStatus | null;
lastRemoveJobId: string | null;
lastRemovedAt: Date | null;
lastRemoveMessage: string | null;
syncOptions: {
initialSyncBehavior: SecretSyncInitialSyncBehavior;
prependPrefix?: string;
appendSuffix?: string;
};
connection: {
app: AppConnection;
id: string;
name: string;
};
environment: {
id: string;
name: string;
slug: string;
};
folder: {
id: string;
path: string;
};
};
@@ -46,5 +46,4 @@ export type SubscriptionPlan = {
pkiEst: boolean;
enforceMfa: boolean;
projectTemplates: boolean;
appConnections: boolean;
};
@@ -44,11 +44,6 @@ export const GitHubOAuthCallbackPage = () => {
// validate state
if (state !== localStorage.getItem("latestCSRFToken")) {
createNotification({
type: "error",
text: "Invalid state, redirecting..."
});
navigate({ to: "/" });
return;
}
@@ -1,14 +1,9 @@
import {
faArrowUpRightFromSquare,
faBookOpen,
faPlus,
faWrench
} from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
@@ -16,24 +11,8 @@ import { AddAppConnectionModal, AppConnectionsTable } from "./components";
export const AppConnectionsTab = withPermission(
() => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const);
// TODO: remove once live
if (!subscription?.appConnections)
return (
<div className="m-auto mt-40 flex w-full max-w-2xl flex-col items-center rounded-md bg-mineshaft-800 px-2 pt-4 text-bunker-300">
<FontAwesomeIcon icon={faWrench} size="2xl" />
<div className="flex flex-col items-center py-4">
<div className="text-lg text-mineshaft-200">
App Connections are currently unavailable.
</div>
<span className="text-mineshaft-300">Check back soon.</span>
</div>
</div>
);
return (
<div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -132,7 +132,7 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
{Object.values(GitHubConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{methodDetails.name}{" "}
{getAppConnectionMethodDetails(method).name}{" "}
{method === GitHubConnectionMethod.App ? " (Recommended)" : ""}
</SelectItem>
);
@@ -29,7 +29,6 @@ import {
THead,
Tr
} from "@app/components/v2";
import { useSubscription } from "@app/context";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
@@ -52,11 +51,7 @@ type AppConnectionFilters = {
};
export const AppConnectionsTable = () => {
const { subscription } = useSubscription();
const { isLoading, data: appConnections = [] } = useListAppConnections({
enabled: subscription?.appConnections
});
const { isLoading, data: appConnections = [] } = useListAppConnections();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteConnection",
@@ -137,7 +137,8 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([])
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretSyncs]: GeneralPolicyActionSchema.array().default([])
})
.partial()
.optional()
@@ -218,7 +219,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Tags,
ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Kms
ProjectPermissionSub.Kms,
ProjectPermissionSub.SecretSyncs
].includes(subject)
) {
// from above statement we are sure it won't be undefined
@@ -670,5 +672,14 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Perform rollback", value: "create" },
{ label: "View", value: "read" }
]
},
[ProjectPermissionSub.SecretSyncs]: {
title: "Secret Syncs",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
}
};
@@ -1,220 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ContentLoader } from "@app/components/v2";
import { Badge, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import {
useDeleteIntegration,
useDeleteIntegrationAuths,
useGetCloudIntegrations,
useGetWorkspaceAuthorizations,
useGetWorkspaceIntegrations
} from "@app/hooks/api";
import { IntegrationAuth } from "@app/hooks/api/types";
import { CloudIntegrationSection } from "./components/CloudIntegrationSection";
import { FrameworkIntegrationSection } from "./components/FrameworkIntegrationSection";
import { InfrastructureIntegrationSection } from "./components/InfrastructureIntegrationSection/InfrastructureIntegrationSection";
import { IntegrationsSection } from "./components/IntegrationsSection";
import { redirectForProviderAuth } from "./IntegrationsListPage.utils";
enum IntegrationView {
List = "list",
New = "new"
}
const Page = () => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const { environments, id: workspaceId } = currentWorkspace;
const [view, setView] = useState<IntegrationView>(IntegrationView.New);
const { data: cloudIntegrations, isPending: isCloudIntegrationsLoading } =
useGetCloudIntegrations();
const {
data: integrationAuths,
isPending: isIntegrationAuthLoading,
isFetching: isIntegrationAuthFetching
} = useGetWorkspaceAuthorizations(
workspaceId,
useCallback((data: IntegrationAuth[]) => {
const groupBy: Record<string, IntegrationAuth> = {};
data.forEach((el) => {
groupBy[el.integration] = el;
});
return groupBy;
}, [])
);
// mutation
const {
data: integrations,
isPending: isIntegrationLoading,
isFetching: isIntegrationFetching,
isFetched: isIntegrationsFetched
} = useGetWorkspaceIntegrations(workspaceId);
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
const {
mutateAsync: deleteIntegrationAuths,
isSuccess: isDeleteIntegrationAuthSuccess,
reset: resetDeleteIntegrationAuths
} = useDeleteIntegrationAuths();
const isIntegrationsAuthorizedEmpty = !Object.keys(integrationAuths || {}).length;
const isIntegrationsEmpty = !integrations?.length;
// summary: this use effect is trigger when all integration auths are removed thus deactivate bot
// details: so on successfully deleting an integration auth, immediately integration list is refeteched
// After the refetch is completed check if its empty. Then set bot active and reset the submit hook for isSuccess to go back to false
useEffect(() => {
if (
isDeleteIntegrationAuthSuccess &&
!isIntegrationFetching &&
!isIntegrationAuthFetching &&
isIntegrationsAuthorizedEmpty &&
isIntegrationsEmpty
) {
resetDeleteIntegrationAuths();
}
}, [
isIntegrationFetching,
isDeleteIntegrationAuthSuccess,
isIntegrationAuthFetching,
isIntegrationsAuthorizedEmpty,
isIntegrationsEmpty
]);
useEffect(() => {
setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
}, [isIntegrationsFetched]);
const handleProviderIntegration = async (provider: string) => {
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
if (!selectedCloudIntegration) return;
try {
redirectForProviderAuth(currentWorkspace.id, navigate, selectedCloudIntegration);
} catch (error) {
console.error(error);
}
};
// function to strat integration for a provider
// confirmation to user passing the bot key for provider to get secret access
const handleProviderIntegrationStart = (provider: string) => {
handleProviderIntegration(provider);
};
const handleIntegrationDelete = async (
integrationId: string,
shouldDeleteIntegrationSecrets: boolean,
cb: () => void
) => {
try {
await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets });
if (cb) cb();
createNotification({
type: "success",
text: "Deleted integration"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete integration"
});
}
};
const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => {
const integrationAuthForProvider = integrationAuths?.[provider];
if (!integrationAuthForProvider) return;
try {
await deleteIntegrationAuths({
integration: provider,
workspaceId
});
if (cb) cb();
createNotification({
type: "success",
text: "Revoked provider authentication"
});
} catch (err) {
console.error(err);
createNotification({
type: "error",
text: "Failed to revoke provider authentication"
});
}
};
if (isIntegrationLoading || isCloudIntegrationsLoading)
return (
<div className="flex flex-col items-center gap-2">
<ContentLoader text={["Loading integrations..."]} />
</div>
);
return (
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
<div className="relative">
{view === IntegrationView.List ? (
<motion.div
key="view-integrations"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<IntegrationsSection
cloudIntegrations={cloudIntegrations}
onAddIntegration={() => setView(IntegrationView.New)}
isLoading={isIntegrationLoading}
integrations={integrations}
environments={environments}
onIntegrationDelete={handleIntegrationDelete}
workspaceId={workspaceId}
/>
</motion.div>
) : (
<motion.div
key="add-integration"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<CloudIntegrationSection
onViewActiveIntegrations={
integrations?.length ? () => setView(IntegrationView.List) : undefined
}
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
cloudIntegrations={cloudIntegrations}
integrationAuths={integrationAuths}
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
/>
<FrameworkIntegrationSection />
<InfrastructureIntegrationSection />
</motion.div>
)}
</div>
</div>
);
};
FrameworkIntegrationTab,
InfrastructureIntegrationTab,
NativeIntegrationsTab,
SecretSyncsTab
} from "./components";
export const IntegrationsListPage = () => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
const { selectedTab } = useSearch({
from: ROUTE_PATHS.SecretManager.IntegrationsListPage.id
});
const updateSelectedTab = (tab: string) => {
navigate({
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
search: (prev) => ({ ...prev, selectedTab: tab }),
params: {
projectId: currentWorkspace.id
}
});
};
return (
<>
<Helmet>
@@ -223,14 +44,90 @@ export const IntegrationsListPage = () => {
<meta property="og:title" content="Manage your .env files in seconds" />
<meta name="og:description" content={t("integrations.description") as string} />
</Helmet>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Integrations}
>
<Page />
</ProjectPermissionCan>
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
<div className="mx-6 mb-8">
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Integrations</h1>
<p className="text-base text-bunker-300">
Manage integrations with third-party services.
</p>
</div>
<div className="mx-2 mb-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1 text-primary" />
Integrations Update
</div>
<p className="mb-2 mt-1 text-sm text-bunker-300">
Infisical is excited to announce{" "}
<a
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/integrations/secret-syncs/overview"
target="_blank"
rel="noopener noreferrer"
>
Secret Syncs
</a>
, a new way to sync your secrets to third-party services using{" "}
<a
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/integrations/app-connections/overview"
target="_blank"
rel="noopener noreferrer"
>
App Connections
</a>
, offering deeper customization and re-configurability.
</p>
<p className="text-sm text-bunker-300">
Existing integrations (now called Native Integrations) will continue to be supported
as we build out our Secret Sync library.
</p>
</div>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={IntegrationsListPageTabs.SecretSyncs}>
Secret Syncs
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
New
</Badge>
</Tab>
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>Native Integrations</Tab>
<Tab value={IntegrationsListPageTabs.FrameworkIntegrations}>
Framework Integrations
</Tab>
<Tab value={IntegrationsListPageTabs.InfrastructureIntegrations}>
Infrastructure Integrations
</Tab>
</TabList>
<TabPanel value={IntegrationsListPageTabs.SecretSyncs}>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SecretSyncs}
>
<SecretSyncsTab />
</ProjectPermissionCan>
</TabPanel>
<TabPanel value={IntegrationsListPageTabs.NativeIntegrations}>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Integrations}
>
<NativeIntegrationsTab />
</ProjectPermissionCan>
</TabPanel>
<TabPanel value={IntegrationsListPageTabs.FrameworkIntegrations}>
<FrameworkIntegrationTab />
</TabPanel>
<TabPanel value={IntegrationsListPageTabs.InfrastructureIntegrations}>
<InfrastructureIntegrationTab />
</TabPanel>
</Tabs>
</div>
</div>
</>
);
};
@@ -87,7 +87,7 @@ export const redirectForProviderAuth = (
},
search: {
clientId: integrationOption.clientId,
state,
state
}
});
break;
@@ -75,12 +75,12 @@ export const CloudIntegrationSection = ({
return (
<div>
<div className="px-5">
{currentWorkspace?.environments.length === 0 && (
{currentWorkspace?.environments.length === 0 && (
<div className="px-5">
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
)}
</div>
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
</div>
)}
<div className="m-4 mt-0 flex flex-col items-start justify-between px-2 text-xl">
{onViewActiveIntegrations && (
<Button
variant="link"
@@ -1 +0,0 @@
export { FrameworkIntegrationSection } from "./FrameworkIntegrationSection";
@@ -5,14 +5,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import frameworks from "../json/frameworkIntegrations.json";
export const FrameworkIntegrationSection = () => {
export const FrameworkIntegrationTab = () => {
const { t } = useTranslation();
const sortedFrameworks = frameworks.sort((a, b) => a.name.localeCompare(b.name));
return (
<>
<div className="mx-4 mb-4 mt-12 flex flex-col items-start justify-between px-2 text-xl">
<div className="mx-4 mb-4 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
</div>
@@ -0,0 +1 @@
export { FrameworkIntegrationTab } from "./FrameworkIntegrationTab";
@@ -1 +0,0 @@
export { InfrastructureIntegrationSection } from "./InfrastructureIntegrationSection";
@@ -1,11 +1,11 @@
import integrations from "../json/infrastructureIntegrations.json";
export const InfrastructureIntegrationSection = () => {
export const InfrastructureIntegrationTab = () => {
const sortedIntegrations = integrations.sort((a, b) => a.name.localeCompare(b.name));
return (
<>
<div className="mx-4 mb-4 mt-12 flex flex-col items-start justify-between px-2 text-xl">
<div className="mx-4 mb-4 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Infrastructure Integrations</h1>
<p className="text-base text-gray-400">
Click on of the integration to read the documentation.
@@ -0,0 +1 @@
export { InfrastructureIntegrationTab } from "./InfrastructureIntegrationTab";
@@ -1,136 +0,0 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Checkbox, DeleteActionModal } from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
import { TCloudIntegration, TIntegration } from "@app/hooks/api/types";
import { IntegrationsTable } from "./components";
type Props = {
environments: Array<{ name: string; slug: string; id: string }>;
integrations?: TIntegration[];
cloudIntegrations?: TCloudIntegration[];
isLoading?: boolean;
onIntegrationDelete: (
integrationId: string,
shouldDeleteIntegrationSecrets: boolean,
cb: () => void
) => Promise<void>;
workspaceId: string;
onAddIntegration: () => void;
};
export const IntegrationsSection = ({
integrations = [],
environments = [],
isLoading,
onIntegrationDelete,
workspaceId,
onAddIntegration,
cloudIntegrations = []
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteConfirmation",
"deleteSecretsConfirmation"
] as const);
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
return (
<div className="mx-6 mb-8">
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Integrations</h1>
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
</div>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={onAddIntegration}
>
Add Integration
</Button>
</div>
<IntegrationsTable
cloudIntegrations={cloudIntegrations}
integrations={integrations}
isLoading={isLoading}
workspaceId={workspaceId}
environments={environments}
onDeleteIntegration={(integration) => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
/>
</div>
<DeleteActionModal
isOpen={popUp.deleteConfirmation.isOpen}
title={`Are you sure want to remove ${
(popUp?.deleteConfirmation.data as TIntegration)?.integration || " "
} integration for ${
(popUp?.deleteConfirmation.data as TIntegration)?.app || "this project"
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)}
deleteKey={
((popUp?.deleteConfirmation?.data as TIntegration)?.integration ===
"azure-app-configuration" &&
(popUp?.deleteConfirmation?.data as TIntegration)?.app
?.split("//")[1]
?.split(".")[0]) ||
(popUp?.deleteConfirmation?.data as TIntegration)?.app ||
(popUp?.deleteConfirmation?.data as TIntegration)?.owner ||
(popUp?.deleteConfirmation?.data as TIntegration)?.path ||
(popUp?.deleteConfirmation?.data as TIntegration)?.integration ||
""
}
onDeleteApproved={async () => {
if (shouldDeleteSecrets) {
handlePopUpOpen("deleteSecretsConfirmation");
return;
}
await onIntegrationDelete(
(popUp?.deleteConfirmation.data as TIntegration).id,
false,
() => handlePopUpClose("deleteConfirmation")
);
}}
>
{(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && (
<div className="mt-4">
<Checkbox
id="delete-integration-secrets"
checkIndicatorBg="text-white"
onCheckedChange={() => setShouldDeleteSecrets.toggle()}
>
Delete previously synced secrets from the destination
</Checkbox>
</div>
)}
</DeleteActionModal>
<DeleteActionModal
isOpen={popUp.deleteSecretsConfirmation.isOpen}
title={`Are you sure you also want to delete secrets on ${
(popUp?.deleteConfirmation.data as TIntegration)?.integration
}?`}
subTitle="By confirming, you acknowledge that all secrets managed by this integration will be removed from the destination. This action is irreversible."
onChange={(isOpen) => handlePopUpToggle("deleteSecretsConfirmation", isOpen)}
deleteKey="confirm"
onDeleteApproved={async () => {
await onIntegrationDelete(
(popUp?.deleteConfirmation.data as TIntegration).id,
true,
() => {
handlePopUpClose("deleteSecretsConfirmation");
handlePopUpClose("deleteConfirmation");
}
);
}}
/>
</div>
);
};
@@ -1 +0,0 @@
export * from "./IntegrationsTable";
@@ -1 +0,0 @@
export { IntegrationsSection } from "./IntegrationsSection";
@@ -7,7 +7,7 @@ import {
faClock,
faFilter,
faMagnifyingGlass,
faPlug,
faRotate,
faSearch,
faWarning
} from "@fortawesome/free-solid-svg-icons";
@@ -85,16 +85,11 @@ export const IntegrationsTable = ({
}: Props) => {
const { mutate: syncIntegration } = useSyncIntegration();
const initialFilters = useMemo(
() => ({
environmentIds: environments.map((env) => env.id),
integrations: [...new Set(integrations.map(({ integration }) => integration))],
status: Object.values(IntegrationStatus)
}),
[environments, integrations]
);
const [filters, setFilters] = useState<IntegrationFilters>(initialFilters);
const [filters, setFilters] = useState<IntegrationFilters>({
status: [],
integrations: [],
environmentIds: []
});
const cloudIntegrationMap = useMemo(() => {
return new Map(
@@ -130,18 +125,33 @@ export const IntegrationsTable = ({
.filter((integration) => {
const { secretPath, envId, isSynced } = integration;
if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false;
if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false)
if (
filters.status.length &&
!filters.status.includes(IntegrationStatus.Synced) &&
isSynced
)
return false;
if (
filters.status.length &&
!filters.status.includes(IntegrationStatus.NotSynced) &&
isSynced === false
)
return false;
if (
filters.status.length &&
!filters.status.includes(IntegrationStatus.PendingSync) &&
typeof isSynced !== "boolean"
)
return false;
if (!filters.integrations.includes(integration.integration)) return false;
if (
filters.integrations.length &&
!filters.integrations.includes(integration.integration)
)
return false;
if (!filters.environmentIds.includes(envId)) return false;
if (filters.environmentIds.length && !filters.environmentIds.includes(envId))
return false;
return (
integration.integration
@@ -216,9 +226,7 @@ export const IntegrationsTable = ({
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered =
filters.integrations.length !== initialFilters.integrations.length ||
filters.environmentIds.length !== initialFilters.environmentIds.length ||
filters.status.length !== initialFilters.status.length;
filters.integrations.length || filters.environmentIds.length || filters.status.length;
return (
<div>
@@ -439,7 +447,7 @@ export const IntegrationsTable = ({
? "No integrations match search..."
: "This project has no integrations configured"
}
icon={integrations.length ? faSearch : faPlug}
icon={integrations.length ? faSearch : faRotate}
/>
)}
</TableContainer>
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, DeleteActionModal, Spinner } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import {
useDeleteIntegration,
useDeleteIntegrationAuths,
useGetCloudIntegrations,
useGetWorkspaceAuthorizations,
useGetWorkspaceIntegrations
} from "@app/hooks/api";
import { IntegrationAuth } from "@app/hooks/api/integrationAuth/types";
import { TIntegration } from "@app/hooks/api/integrations/types";
import { redirectForProviderAuth } from "../../IntegrationsListPage.utils";
import { CloudIntegrationSection } from "../CloudIntegrationSection";
import { IntegrationsTable } from "./IntegrationsTable";
enum IntegrationView {
List = "list",
New = "new"
}
export const NativeIntegrationsTab = () => {
const { currentWorkspace } = useWorkspace();
const { environments, id: workspaceId } = currentWorkspace;
const navigate = useNavigate();
const { data: cloudIntegrations, isPending: isCloudIntegrationsLoading } =
useGetCloudIntegrations();
const {
data: integrationAuths,
isPending: isIntegrationAuthLoading,
isFetching: isIntegrationAuthFetching
} = useGetWorkspaceAuthorizations(
workspaceId,
useCallback((data: IntegrationAuth[]) => {
const groupBy: Record<string, IntegrationAuth> = {};
data.forEach((el) => {
groupBy[el.integration] = el;
});
return groupBy;
}, [])
);
// mutation
const {
data: integrations,
isPending: isIntegrationLoading,
isFetching: isIntegrationFetching
} = useGetWorkspaceIntegrations(workspaceId);
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
const {
mutateAsync: deleteIntegrationAuths,
isSuccess: isDeleteIntegrationAuthSuccess,
reset: resetDeleteIntegrationAuths
} = useDeleteIntegrationAuths();
const isIntegrationsAuthorizedEmpty = !Object.keys(integrationAuths || {}).length;
const isIntegrationsEmpty = !integrations?.length;
// summary: this use effect is trigger when all integration auths are removed thus deactivate bot
// details: so on successfully deleting an integration auth, immediately integration list is refeteched
// After the refetch is completed check if its empty. Then set bot active and reset the submit hook for isSuccess to go back to false
useEffect(() => {
if (
isDeleteIntegrationAuthSuccess &&
!isIntegrationFetching &&
!isIntegrationAuthFetching &&
isIntegrationsAuthorizedEmpty &&
isIntegrationsEmpty
) {
resetDeleteIntegrationAuths();
}
}, [
isIntegrationFetching,
isDeleteIntegrationAuthSuccess,
isIntegrationAuthFetching,
isIntegrationsAuthorizedEmpty,
isIntegrationsEmpty
]);
// useEffect(() => {
// setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
// }, [isIntegrationsFetched]);
const handleProviderIntegration = async (provider: string) => {
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
if (!selectedCloudIntegration) return;
try {
redirectForProviderAuth(currentWorkspace.id, navigate, selectedCloudIntegration);
} catch (error) {
console.error(error);
}
};
// function to strat integration for a provider
// confirmation to user passing the bot key for provider to get secret access
const handleProviderIntegrationStart = (provider: string) => {
handleProviderIntegration(provider);
};
const handleIntegrationDelete = async (
integrationId: string,
shouldDeleteIntegrationSecrets: boolean,
cb: () => void
) => {
try {
await deleteIntegration({ id: integrationId, workspaceId, shouldDeleteIntegrationSecrets });
if (cb) cb();
createNotification({
type: "success",
text: "Deleted integration"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete integration"
});
}
};
const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => {
const integrationAuthForProvider = integrationAuths?.[provider];
if (!integrationAuthForProvider) return;
try {
await deleteIntegrationAuths({
integration: provider,
workspaceId
});
if (cb) cb();
createNotification({
type: "success",
text: "Revoked provider authentication"
});
} catch (err) {
console.error(err);
createNotification({
type: "error",
text: "Failed to revoke provider authentication"
});
}
};
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteConfirmation",
"deleteSecretsConfirmation"
] as const);
const [view, setView] = useState<IntegrationView>(IntegrationView.List);
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
if (isIntegrationLoading || isCloudIntegrationsLoading)
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
<Spinner />
</div>
);
return (
<>
{view === IntegrationView.List ? (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Native Integrations</p>
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => setView(IntegrationView.New)}
>
Add Integration
</Button>
</div>
<IntegrationsTable
cloudIntegrations={cloudIntegrations}
integrations={integrations}
isLoading={isIntegrationLoading}
workspaceId={workspaceId}
environments={environments}
onDeleteIntegration={(integration) => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
/>
</div>
) : (
<CloudIntegrationSection
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
integrationAuths={integrationAuths}
cloudIntegrations={cloudIntegrations}
isLoading={isIntegrationAuthLoading || isCloudIntegrationsLoading}
onViewActiveIntegrations={() => setView(IntegrationView.List)}
/>
)}
<DeleteActionModal
isOpen={popUp.deleteConfirmation.isOpen}
title={`Are you sure want to remove ${
(popUp?.deleteConfirmation.data as TIntegration)?.integration || " "
} integration for ${
(popUp?.deleteConfirmation.data as TIntegration)?.app || "this project"
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)}
deleteKey={
((popUp?.deleteConfirmation?.data as TIntegration)?.integration ===
"azure-app-configuration" &&
(popUp?.deleteConfirmation?.data as TIntegration)?.app
?.split("//")[1]
?.split(".")[0]) ||
(popUp?.deleteConfirmation?.data as TIntegration)?.app ||
(popUp?.deleteConfirmation?.data as TIntegration)?.owner ||
(popUp?.deleteConfirmation?.data as TIntegration)?.path ||
(popUp?.deleteConfirmation?.data as TIntegration)?.integration ||
""
}
onDeleteApproved={async () => {
if (shouldDeleteSecrets) {
handlePopUpOpen("deleteSecretsConfirmation");
return;
}
await handleIntegrationDelete(
(popUp?.deleteConfirmation.data as TIntegration).id,
false,
() => handlePopUpClose("deleteConfirmation")
);
}}
>
{(popUp?.deleteConfirmation?.data as TIntegration)?.integration === "github" && (
<div className="mt-4">
<Checkbox
id="delete-integration-secrets"
checkIndicatorBg="text-white"
onCheckedChange={() => setShouldDeleteSecrets.toggle()}
>
Delete previously synced secrets from the destination
</Checkbox>
</div>
)}
</DeleteActionModal>
<DeleteActionModal
isOpen={popUp.deleteSecretsConfirmation.isOpen}
title={`Are you sure you also want to delete secrets on ${
(popUp?.deleteConfirmation.data as TIntegration)?.integration
}?`}
subTitle="By confirming, you acknowledge that all secrets managed by this integration will be removed from the destination. This action is irreversible."
onChange={(isOpen) => handlePopUpToggle("deleteSecretsConfirmation", isOpen)}
deleteKey="confirm"
onDeleteApproved={async () => {
await handleIntegrationDelete(
(popUp?.deleteConfirmation.data as TIntegration).id,
true,
() => {
handlePopUpClose("deleteSecretsConfirmation");
handlePopUpClose("deleteConfirmation");
}
);
}}
/>
</>
);
};
@@ -0,0 +1 @@
export * from "./NativeIntegrationsTab";
@@ -0,0 +1,14 @@
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TAwsParameterStoreSync;
};
export const AwsParameterStoreSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -0,0 +1,49 @@
import { GitHubSyncSelectedRepositoriesTooltipContent } from "@app/components/secret-syncs/github/GitHubSyncSelectedRepositoriesTooltipContent";
import {
GitHubSyncScope,
GitHubSyncVisibility,
TGitHubSync
} from "@app/hooks/api/secretSyncs/types/github-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell, SecretSyncTableCellProps } from "../SecretSyncTableCell";
type Props = {
secretSync: TGitHubSync;
};
export const GitHubSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
const { destinationConfig } = secretSync;
let additionalProps: Pick<
SecretSyncTableCellProps,
"additionalTooltipContent" | "infoBadge" | "secondaryClassName"
> = {};
if (
destinationConfig.scope === GitHubSyncScope.Organization &&
destinationConfig.visibility === GitHubSyncVisibility.Selected
) {
additionalProps = {
infoBadge: "secondary",
additionalTooltipContent: (
<div className="mt-4">
<GitHubSyncSelectedRepositoriesTooltipContent secretSync={secretSync} />
</div>
)
};
}
return (
<SecretSyncTableCell
primaryText={primaryText}
secondaryText={secondaryText}
{...additionalProps}
secondaryClassName={
destinationConfig.scope === GitHubSyncScope.Organization ? "capitalize" : undefined
}
/>
);
};
@@ -0,0 +1,21 @@
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncDestinationCol } from "./AwsParameterStoreSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
type Props = {
secretSync: TSecretSync;
};
export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
return <AwsParameterStoreSyncDestinationCol secretSync={secretSync} />;
case SecretSync.GitHub:
return <GitHubSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
);
}
};
@@ -0,0 +1,341 @@
import { useCallback, useMemo } from "react";
import {
faBan,
faCalendarCheck,
faCheck,
faCopy,
faDownload,
faEllipsisV,
faEraser,
faInfoCircle,
faRotate,
faToggleOff,
faToggleOn,
faTrash,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
SecretSyncImportStatusBadge,
SecretSyncRemoveStatusBadge,
SecretSyncStatusBadge
} from "@app/components/secret-syncs";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { useToggle } from "@app/hooks";
import { SecretSyncStatus, TSecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs";
import { SecretSyncDestinationCol } from "@app/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol";
import { SecretSyncTableCell } from "./SecretSyncTableCell";
type Props = {
secretSync: TSecretSync;
onDelete: (secretSync: TSecretSync) => void;
onTriggerSyncSecrets: (secretSync: TSecretSync) => void;
onTriggerImportSecrets: (secretSync: TSecretSync) => void;
onTriggerRemoveSecrets: (secretSync: TSecretSync) => void;
onToggleEnable: (secretSync: TSecretSync) => void;
};
export const SecretSyncRow = ({
secretSync,
onDelete,
onTriggerSyncSecrets,
onTriggerImportSecrets,
onTriggerRemoveSecrets,
onToggleEnable
}: Props) => {
const navigate = useNavigate();
const {
id,
folder: { path: secretPath },
lastSyncMessage,
destination,
lastSyncedAt,
environment,
name,
description,
syncStatus,
isEnabled,
projectId
} = secretSync;
const { syncOption } = useSecretSyncOption(destination);
const destinationName = SECRET_SYNC_MAP[destination].name;
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(id);
createNotification({
text: "Secret Sync ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const failureMessage = useMemo(() => {
if (syncStatus === SecretSyncStatus.Failed) {
if (lastSyncMessage)
try {
return JSON.stringify(JSON.parse(lastSyncMessage), null, 2);
} catch {
return lastSyncMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [syncStatus, lastSyncMessage]);
const destinationDetails = SECRET_SYNC_MAP[destination];
return (
<Tr
onClick={() =>
navigate({
to: ROUTE_PATHS.SecretManager.SecretSyncDetailsByIDPage.path,
params: {
syncId: id,
destination,
projectId
}
})
}
className={twMerge(
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
syncStatus === SecretSyncStatus.Failed && "bg-red/5 hover:bg-red/10",
!isEnabled && "bg-mineshaft-400/15 opacity-50"
)}
key={`sync-${id}`}
>
<Td>
<img
alt={`${destinationDetails.name} sync`}
src={`/images/integrations/${destinationDetails.image}`}
className="min-w-[1.75rem]"
/>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div>
<div className="flex w-full items-center">
<p className="truncate">{name}</p>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon
icon={faInfoCircle}
size="xs"
className="ml-1 text-mineshaft-400"
/>
</Tooltip>
)}
</div>
<p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p>
</div>
</Td>
<SecretSyncTableCell primaryText={secretPath} secondaryText={environment.name} />
<SecretSyncDestinationCol secretSync={secretSync} />
<Td>
<div className="flex items-center gap-1">
{isEnabled ? (
syncStatus && (
<Tooltip
position="left"
className="max-w-sm"
content={
[SecretSyncStatus.Succeeded, SecretSyncStatus.Failed].includes(syncStatus) ? (
<div className="flex flex-col gap-2 whitespace-normal py-1">
{lastSyncedAt && (
<div>
<div
className={`mb-2 flex self-start ${syncStatus === SecretSyncStatus.Failed ? "text-yellow" : "text-green"}`}
>
<FontAwesomeIcon
icon={faCalendarCheck}
className="ml-1 pr-1.5 pt-0.5 text-sm"
/>
<div className="text-xs">Last Synced</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">
{format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")}
</div>
</div>
)}
{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">Failure Reason</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">
{failureMessage}
</div>
</div>
)}
</div>
) : undefined
}
>
<div>
<SecretSyncStatusBadge status={syncStatus} />
</div>
</Tooltip>
)
) : (
<Badge className="flex w-min items-center gap-1.5 bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
<span>Disabled</span>
</Badge>
)}
<SecretSyncImportStatusBadge mini secretSync={secretSync} />
<SecretSyncRemoveStatusBadge mini secretSync={secretSync} />
</div>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId();
}}
>
Copy Sync ID
</DropdownMenuItem>
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faRotate} />}
onClick={(e) => {
e.stopPropagation();
onTriggerSyncSecrets(secretSync);
}}
>
<Tooltip
position="left"
sideOffset={42}
content={`Manually trigger a sync for this ${destinationName} destination.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span> Trigger Sync</span>
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faInfoCircle} />
</div>
</Tooltip>
</DropdownMenuItem>
{syncOption?.canImportSecrets && (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faDownload} />}
onClick={(e) => {
e.stopPropagation();
onTriggerImportSecrets(secretSync);
}}
>
<Tooltip
position="left"
sideOffset={42}
content={`Import secrets from this ${destinationName} destination into Infisical.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span>Import Secrets</span>
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faInfoCircle} />
</div>
</Tooltip>
</DropdownMenuItem>
)}
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faEraser} />}
onClick={(e) => {
e.stopPropagation();
onTriggerRemoveSecrets(secretSync);
}}
>
<Tooltip
position="left"
sideOffset={42}
content={`Remove secrets synced by Infisical from this ${destinationName} destination.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span>Remove Secrets</span>
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faInfoCircle} />
</div>
</Tooltip>
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={isEnabled ? faToggleOff : faToggleOn} />}
onClick={(e) => {
e.stopPropagation();
onToggleEnable(secretSync);
}}
>
{isEnabled ? "Disable" : "Enable"} Sync
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={(e) => {
e.stopPropagation();
onDelete(secretSync);
}}
>
Delete Sync
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,67 @@
import { ReactNode } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Td, Tooltip } from "@app/components/v2";
export type SecretSyncTableCellProps = {
primaryText: string;
secondaryText?: string;
infoBadge?: "primary" | "secondary";
additionalTooltipContent?: ReactNode;
primaryClassName?: string;
secondaryClassName?: string;
};
export const SecretSyncTableCell = ({
primaryText,
secondaryText,
infoBadge,
additionalTooltipContent,
primaryClassName,
secondaryClassName
}: SecretSyncTableCellProps) => {
return (
<Td className="!min-w-[8rem] max-w-0">
<Tooltip
side="left"
className="max-w-2xl break-words"
content={
<>
<p className="text-sm">{primaryText}</p>
{secondaryText && <p className="text-xs leading-3 text-bunker-300">{secondaryText}</p>}
{additionalTooltipContent}
</>
}
>
<div>
<p className={twMerge("truncate text-sm", primaryClassName)}>
{primaryText}
{infoBadge === "primary" && (
<FontAwesomeIcon
size="xs"
icon={faInfoCircle}
className="ml-1 inline-block text-bunker-300"
/>
)}
</p>
{secondaryText && (
<p
className={twMerge("truncate text-xs leading-4 text-bunker-300", secondaryClassName)}
>
{secondaryText}
{infoBadge === "secondary" && (
<FontAwesomeIcon
size="xs"
icon={faInfoCircle}
className="ml-1 inline-block text-bunker-300"
/>
)}
</p>
)}
</div>
</Tooltip>
</Td>
);
};
@@ -0,0 +1,485 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faBan,
faCheck,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faRotate,
faSearch,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
DeleteSecretSyncModal,
SecretSyncImportSecretsModal,
SecretSyncRemoveSecretsModal
} from "@app/components/secret-syncs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
SecretSync,
TSecretSync,
useTriggerSecretSyncSyncSecrets,
useUpdateSecretSync
} from "@app/hooks/api/secretSyncs";
import { getSecretSyncDestinationColValues } from "./helpers";
import { SecretSyncRow } from "./SecretSyncRow";
enum SecretSyncsOrderBy {
Destination = "destination",
Source = "source",
Name = "name",
Status = "status"
}
type SecretSyncFilters = {
destinations: SecretSync[];
status: SecretSyncStatusCol[];
environmentIds: string[];
};
enum SecretSyncStatusCol {
Pending = "pending",
Success = "success",
Failed = "failed",
Disabled = "disabled"
}
type Props = {
secretSyncs: TSecretSync[];
};
const STATUS_ICON_MAP = {
[SecretSyncStatusCol.Success]: { icon: faCheck, className: "text-green", name: "Synced" },
[SecretSyncStatusCol.Failed]: { icon: faWarning, className: "text-red", name: "Not Synced" },
[SecretSyncStatusCol.Pending]: { icon: faRotate, className: "text-yellow", name: "Syncing" },
[SecretSyncStatusCol.Disabled]: { icon: faBan, className: "text-mineshaft-400", name: "Disabled" }
};
export const SecretSyncsTable = ({ secretSyncs }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteSync",
"importSecrets",
"removeSecrets"
] as const);
const triggerSync = useTriggerSecretSyncSyncSecrets();
const updateSync = useUpdateSecretSync();
const [filters, setFilters] = useState<SecretSyncFilters>({
destinations: [],
status: [],
environmentIds: []
});
const { currentWorkspace } = useWorkspace();
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<SecretSyncsOrderBy>(SecretSyncsOrderBy.Name, { initPerPage: 20 });
const filteredSecretSyncs = useMemo(
() =>
secretSyncs
.filter((secretSync) => {
const { destination, name, connection, folder, environment, syncStatus, isEnabled } =
secretSync;
if (filters.destinations.length && !filters.destinations.includes(destination))
return false;
if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id))
return false;
const status = isEnabled ? syncStatus : SecretSyncStatusCol.Disabled;
if (
filters.status.length &&
(!status || !filters.status.includes(status as SecretSyncStatusCol))
) {
return false;
}
const searchValue = search.trim().toLowerCase();
const destinationValues = getSecretSyncDestinationColValues(secretSync);
return (
SECRET_SYNC_MAP[destination].name.toLowerCase().includes(searchValue) ||
name.toLowerCase().includes(searchValue) ||
folder.path.toLowerCase().includes(searchValue) ||
environment.name.toLowerCase().includes(searchValue) ||
connection.name.toLowerCase().includes(searchValue) ||
destinationValues.primaryText.toLowerCase().includes(searchValue) ||
destinationValues.secondaryText?.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case SecretSyncsOrderBy.Source:
return syncOne.folder.path
.toLowerCase()
.localeCompare(syncTwo.folder.path.toLowerCase());
case SecretSyncsOrderBy.Destination:
return getSecretSyncDestinationColValues(syncOne)
.primaryText.toLowerCase()
.localeCompare(
getSecretSyncDestinationColValues(syncTwo).primaryText.toLowerCase()
);
case SecretSyncsOrderBy.Status:
return syncOne.connection.name
.toLowerCase()
.localeCompare(syncTwo.connection.name.toLowerCase());
case SecretSyncsOrderBy.Name:
default:
return syncOne.name.toLowerCase().localeCompare(syncTwo.name.toLowerCase());
}
}),
[secretSyncs, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredSecretSyncs.length,
offset,
setPage
});
const handleSort = (column: SecretSyncsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: SecretSyncsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: SecretSyncsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.destinations.length);
const handleDelete = (secretSync: TSecretSync) => handlePopUpOpen("deleteSync", secretSync);
const handleTriggerImportSecrets = (secretSync: TSecretSync) =>
handlePopUpOpen("importSecrets", secretSync);
const handleTriggerRemoveSecrets = (secretSync: TSecretSync) =>
handlePopUpOpen("removeSecrets", secretSync);
const handleToggleEnableSync = async (secretSync: TSecretSync) => {
const destinationName = SECRET_SYNC_MAP[secretSync.destination].name;
const isEnabled = !secretSync.isEnabled;
try {
await updateSync.mutateAsync({
syncId: secretSync.id,
destination: secretSync.destination,
isEnabled
});
createNotification({
text: `Successfully ${isEnabled ? "enabled" : "disabled"} ${destinationName} Sync`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${isEnabled ? "enable" : "disable"} ${destinationName} Sync`,
type: "error"
});
}
};
const handleTriggerSync = async (secretSync: TSecretSync) => {
const destinationName = SECRET_SYNC_MAP[secretSync.destination].name;
try {
await triggerSync.mutateAsync({
syncId: secretSync.id,
destination: secretSync.destination
});
createNotification({
text: `Successfully triggered ${destinationName} Sync`,
type: "success"
});
} catch {
createNotification({
text: `Failed to trigger ${destinationName} Sync`,
type: "error"
});
}
};
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search secret syncs..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter secret syncs"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Status</DropdownMenuLabel>
{Object.values(SecretSyncStatusCol).map((status) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((s) => s !== status)
: [...prev.status, status]
}));
}}
key={status}
icon={
filters.status.includes(status) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={STATUS_ICON_MAP[status].icon}
className={STATUS_ICON_MAP[status].className}
/>
<span className="capitalize">{STATUS_ICON_MAP[status].name}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Service</DropdownMenuLabel>
{secretSyncs.length ? (
[...new Set(secretSyncs.map(({ destination }) => destination))].map((destination) => {
const { name, image } = SECRET_SYNC_MAP[destination];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
destinations: prev.destinations.includes(destination)
? prev.destinations.filter((a) => a !== destination)
: [...prev.destinations, destination]
}));
}}
key={destination}
icon={
filters.destinations.includes(destination) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} integration`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Secret Syncs Configured</DropdownMenuItem>
)}
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{currentWorkspace.environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-2" />
<Th className="w-1/4">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(SecretSyncsOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(SecretSyncsOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Source
<IconButton
variant="plain"
className={getClassName(SecretSyncsOrderBy.Source)}
ariaLabel="sort"
onClick={() => handleSort(SecretSyncsOrderBy.Source)}
>
<FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Source)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Destination
<IconButton
variant="plain"
className={getClassName(SecretSyncsOrderBy.Destination)}
ariaLabel="sort"
onClick={() => handleSort(SecretSyncsOrderBy.Destination)}
>
<FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Destination)} />
</IconButton>
</div>
</Th>
<Th className="min-w-[10rem]">
<div className="flex items-center">
Status
<IconButton
variant="plain"
className={getClassName(SecretSyncsOrderBy.Status)}
ariaLabel="sort"
onClick={() => handleSort(SecretSyncsOrderBy.Status)}
>
<FontAwesomeIcon icon={getColSortIcon(SecretSyncsOrderBy.Status)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredSecretSyncs.slice(offset, perPage * page).map((secretSync) => (
<SecretSyncRow
key={secretSync.id}
secretSync={secretSync}
onDelete={handleDelete}
onTriggerSyncSecrets={handleTriggerSync}
onTriggerImportSecrets={handleTriggerImportSecrets}
onTriggerRemoveSecrets={handleTriggerRemoveSecrets}
onToggleEnable={handleToggleEnableSync}
/>
))}
</TBody>
</Table>
{Boolean(filteredSecretSyncs.length) && (
<Pagination
count={filteredSecretSyncs.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!filteredSecretSyncs?.length && (
<EmptyState
title={
secretSyncs.length
? "No syncs match search..."
: "This project has no syncs configured"
}
icon={secretSyncs.length ? faSearch : faRotate}
/>
)}
</TableContainer>
<DeleteSecretSyncModal
onOpenChange={(isOpen) => handlePopUpToggle("deleteSync", isOpen)}
isOpen={popUp.deleteSync.isOpen}
secretSync={popUp.deleteSync.data}
/>
<SecretSyncImportSecretsModal
onOpenChange={(isOpen) => handlePopUpToggle("importSecrets", isOpen)}
isOpen={popUp.importSecrets.isOpen}
secretSync={popUp.importSecrets.data}
/>
<SecretSyncRemoveSecretsModal
onOpenChange={(isOpen) => handlePopUpToggle("removeSecrets", isOpen)}
isOpen={popUp.removeSecrets.isOpen}
secretSync={popUp.removeSecrets.data}
/>
</div>
);
};
@@ -0,0 +1,50 @@
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync";
// This functional ensures parity across what is displayed in the destination column
// and the values used when search filtering
export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
let primaryText: string;
let secondaryText: string | undefined;
const { destination, destinationConfig } = secretSync;
switch (destination) {
case SecretSync.AWSParameterStore:
primaryText = destinationConfig.path;
secondaryText = destinationConfig.region;
break;
case SecretSync.GitHub:
switch (destinationConfig.scope) {
case GitHubSyncScope.Organization:
primaryText = destinationConfig.org;
if (destinationConfig.visibility === GitHubSyncVisibility.Selected) {
secondaryText = `Organization - ${destinationConfig.selectedRepositoryIds?.length ?? 0} Repositories`;
} else {
secondaryText = `Organization - ${destinationConfig.visibility} Repositories`;
}
break;
case GitHubSyncScope.Repository:
primaryText = `${destinationConfig.owner}/${destinationConfig.repo}`;
secondaryText = "Repository";
break;
case GitHubSyncScope.RepositoryEnvironment:
primaryText = `${destinationConfig.owner}/${destinationConfig.repo}`;
secondaryText = `Environment - ${destinationConfig.env}`;
break;
default:
throw new Error(`Unhandled GitHub Scope Destination Col Values ${destination}`);
}
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
return {
primaryText,
secondaryText
};
};
@@ -0,0 +1 @@
export * from "./SecretSyncsTable";
@@ -0,0 +1,83 @@
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CreateSecretSyncModal } from "@app/components/secret-syncs";
import { Button, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useListSecretSyncs } from "@app/hooks/api/secretSyncs";
import { SecretSyncsTable } from "./SecretSyncTable";
export const SecretSyncsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const);
const { currentWorkspace } = useWorkspace();
const { data: secretSyncs = [], isPending: isSecretSyncsPending } = useListSecretSyncs(
currentWorkspace.id,
{
refetchInterval: 2000
}
);
if (isSecretSyncsPending)
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
<Spinner />
</div>
);
return (
<>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Secret Syncs</p>
<a
href="https://infisical.com/docs/integrations/secret-syncs/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Use App Connections to sync secrets to third-party services.
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addSync")}
isDisabled={!isAllowed}
>
Add Sync
</Button>
)}
</ProjectPermissionCan>
</div>
<SecretSyncsTable secretSyncs={secretSyncs} />
</div>
<CreateSecretSyncModal
isOpen={popUp.addSync.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)}
/>
</>
);
};
@@ -0,0 +1 @@
export * from "./SecretSyncsTab";
@@ -0,0 +1,4 @@
export * from "./FrameworkIntegrationTab";
export * from "./InfrastructureIntegrationTab";
export * from "./NativeIntegrationsTab";
export * from "./SecretSyncsTab";
@@ -1,9 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import { IntegrationsListPage } from "./IntegrationsListPage";
const IntegrationsListPageQuerySchema = z.object({
selectedTab: z.string().catch(IntegrationsListPageTabs.NativeIntegrations)
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/integrations/"
)({
component: IntegrationsListPage
component: IntegrationsListPage,
validateSearch: zodValidator(IntegrationsListPageQuerySchema)
});
@@ -0,0 +1,150 @@
import { Helmet } from "react-helmet";
import { faBan, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EditSecretSyncModal } from "@app/components/secret-syncs";
import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
import { Button, ContentLoader, EmptyState } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { usePopUp } from "@app/hooks";
import { SecretSync, useGetSecretSync } from "@app/hooks/api/secretSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import {
SecretSyncActionTriggers,
SecretSyncAuditLogsSection,
SecretSyncDestinationSection,
SecretSyncDetailsSection,
SecretSyncOptionsSection,
SecretSyncSourceSection
} from "./components";
const PageContent = () => {
const navigate = useNavigate();
const { destination, syncId, projectId } = useParams({
from: ROUTE_PATHS.SecretManager.SecretSyncDetailsByIDPage.id,
select: (params) => ({
...params,
destination: params.destination as SecretSync
})
});
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["editSync"] as const);
const { data: secretSync, isPending } = useGetSecretSync(destination, syncId, {
refetchInterval: 4000
});
if (isPending) {
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader />
</div>
);
}
if (!secretSync) {
return (
<div className="flex h-full w-full items-center justify-center px-20">
<EmptyState
className="max-w-2xl rounded-md text-center"
icon={faBan}
title={`Could not find ${SECRET_SYNC_MAP[destination].name ?? "Secret"} Sync with ID ${syncId}`}
/>
</div>
);
}
const destinationDetails = SECRET_SYNC_MAP[secretSync.destination];
const handleEditDetails = () => handlePopUpOpen("editSync", SecretSyncEditFields.Details);
const handleEditSource = () => handlePopUpOpen("editSync", SecretSyncEditFields.Source);
const handleEditOptions = () => handlePopUpOpen("editSync", SecretSyncEditFields.Options);
const handleEditDestination = () => handlePopUpOpen("editSync", SecretSyncEditFields.Destination);
return (
<>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
params: {
projectId
},
search: {
selectedTab: IntegrationsListPageTabs.SecretSyncs
}
});
}}
className="mb-4"
>
Secret Syncs
</Button>
<div className="mb-6 flex w-full items-center gap-3">
<img
alt={`${destinationDetails.name} sync`}
src={`/images/integrations/${destinationDetails.image}`}
className="ml-1 mt-3 w-16"
/>
<div>
<p className="text-3xl font-semibold text-white">{secretSync.name}</p>
<p className="leading-3 text-bunker-300">{destinationDetails.name} Sync</p>
</div>
<SecretSyncActionTriggers secretSync={secretSync} />
</div>
<div className="flex justify-center">
<div className="mr-4 flex w-72 flex-col gap-4">
<SecretSyncDetailsSection secretSync={secretSync} onEditDetails={handleEditDetails} />
<SecretSyncSourceSection secretSync={secretSync} onEditSource={handleEditSource} />
<SecretSyncOptionsSection secretSync={secretSync} onEditOptions={handleEditOptions} />
</div>
<div className="flex flex-1 flex-col gap-4">
<SecretSyncDestinationSection
secretSync={secretSync}
onEditDestination={handleEditDestination}
/>
<SecretSyncAuditLogsSection secretSync={secretSync} />
</div>
</div>
</div>
</div>
<EditSecretSyncModal
isOpen={popUp.editSync.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editSync", isOpen)}
fields={popUp.editSync.data}
secretSync={secretSync}
/>
</>
);
};
export const SecretSyncDetailsByIDPage = () => {
return (
<>
<Helmet>
<title>Secret Sync | Infisical</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<ProjectPermissionCan
renderGuardBanner
passThrough={false}
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SecretSyncs}
>
<PageContent />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,254 @@
import { useCallback } from "react";
import {
faCheck,
faCopy,
faDownload,
faEllipsisV,
faEraser,
faInfoCircle,
faRotate,
faToggleOff,
faToggleOn,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DeleteSecretSyncModal,
SecretSyncImportSecretsModal,
SecretSyncImportStatusBadge,
SecretSyncRemoveSecretsModal,
SecretSyncRemoveStatusBadge
} from "@app/components/secret-syncs";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { usePopUp, useToggle } from "@app/hooks";
import {
TSecretSync,
useSecretSyncOption,
useTriggerSecretSyncSyncSecrets,
useUpdateSecretSync
} from "@app/hooks/api/secretSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
type Props = {
secretSync: TSecretSync;
};
export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"importSecrets",
"removeSecrets",
"deleteSync"
] as const);
const navigate = useNavigate();
const triggerSyncSecrets = useTriggerSecretSyncSyncSecrets();
const updateSync = useUpdateSecretSync();
const { destination } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const { syncOption } = useSecretSyncOption(destination);
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(secretSync.id);
createNotification({
text: "Secret Sync ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const handleToggleEnableSync = async () => {
const isEnabled = !secretSync.isEnabled;
try {
await updateSync.mutateAsync({
syncId: secretSync.id,
destination: secretSync.destination,
isEnabled
});
createNotification({
text: `Successfully ${isEnabled ? "enabled" : "disabled"} ${destinationName} Sync`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${isEnabled ? "enable" : "disable"} ${destinationName} Sync`,
type: "error"
});
}
};
const handleTriggerSync = async () => {
try {
await triggerSyncSecrets.mutateAsync({
syncId: secretSync.id,
destination: secretSync.destination
});
createNotification({
text: `Successfully triggered ${destinationName} Sync`,
type: "success"
});
} catch {
createNotification({
text: `Failed to trigger ${destinationName} Sync`,
type: "error"
});
}
};
return (
<>
<div className="ml-auto mt-4 flex flex-wrap items-center justify-end gap-2">
<SecretSyncImportStatusBadge secretSync={secretSync} />
<SecretSyncRemoveStatusBadge secretSync={secretSync} />
<div>
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faRotate} />}
onClick={handleTriggerSync}
className="h-9 rounded-r-none bg-mineshaft-500"
>
Trigger Sync
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add-folder-or-import"
variant="outline_bg"
className="h-9 w-10 rounded-l-none border-l-2 border-mineshaft border-l-mineshaft-700 bg-mineshaft-500"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={(e) => {
e.stopPropagation();
handleCopyId();
}}
>
Copy Sync ID
</DropdownMenuItem>
{syncOption?.canImportSecrets && (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faDownload} />}
onClick={() => handlePopUpOpen("importSecrets")}
>
<Tooltip
position="left"
sideOffset={42}
content={`Import secrets from this ${destinationName} destination into Infisical.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span>Import Secrets</span>
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faInfoCircle} />
</div>
</Tooltip>
</DropdownMenuItem>
)}
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faEraser} />}
onClick={() => handlePopUpOpen("removeSecrets")}
>
<Tooltip
position="left"
sideOffset={42}
content={`Remove secrets synced by Infisical from this ${destinationName} destination.`}
>
<div className="flex h-full w-full items-center justify-between gap-1">
<span>Remove Secrets</span>
<FontAwesomeIcon className="text-bunker-300" size="sm" icon={faInfoCircle} />
</div>
</Tooltip>
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={
<FontAwesomeIcon icon={secretSync.isEnabled ? faToggleOff : faToggleOn} />
}
onClick={handleToggleEnableSync}
>
{secretSync.isEnabled ? "Disable" : "Enable"} Sync
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => handlePopUpOpen("deleteSync")}
>
Delete Sync
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<SecretSyncImportSecretsModal
onOpenChange={(isOpen) => handlePopUpToggle("importSecrets", isOpen)}
isOpen={popUp.importSecrets.isOpen}
secretSync={secretSync}
/>
<SecretSyncRemoveSecretsModal
onOpenChange={(isOpen) => handlePopUpToggle("removeSecrets", isOpen)}
isOpen={popUp.removeSecrets.isOpen}
secretSync={secretSync}
/>
<DeleteSecretSyncModal
onOpenChange={(isOpen) => handlePopUpToggle("deleteSync", isOpen)}
isOpen={popUp.deleteSync.isOpen}
secretSync={secretSync}
onComplete={() =>
navigate({
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
params: {
projectId: secretSync.projectId
},
search: {
selectedTab: IntegrationsListPageTabs.SecretSyncs
}
})
}
/>
</>
);
};
@@ -0,0 +1,86 @@
import { faFingerprint } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { useSubscription } from "@app/context";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection";
// Add more events if needed
const INTEGRATION_EVENTS = [
EventType.SECRET_SYNC_SYNC_SECRETS,
EventType.SECRET_SYNC_REMOVE_SECRETS,
EventType.SECRET_SYNC_IMPORT_SECRETS
];
type Props = {
secretSync: TSecretSync;
};
export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
const { subscription } = useSubscription();
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
return (
<div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Sync Logs</h3>
{subscription.auditLogs && (
<p className="text-xs text-bunker-300">
Displaying audit logs from the last {auditLogsRetentionDays} days
</p>
)}
</div>
{subscription.auditLogs ? (
<LogsSection
refetchInterval={4000}
remappedHeaders={{
Metadata: "Sync Status"
}}
showFilters={false}
presets={{
eventMetadata: { syncId: secretSync.id },
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
eventType: INTEGRATION_EVENTS
}}
filterClassName="bg-mineshaft-900 static"
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg bg-mineshaft-800 text-sm text-mineshaft-200">
<div className="flex flex-col items-center gap-4 py-20">
<FontAwesomeIcon size="2x" icon={faFingerprint} />
<p>
Please{" "}
{subscription && subscription.slug !== null ? (
<Link to="/organization/billing" target="_blank" rel="noopener noreferrer">
<a
className="cursor-pointer underline transition-all hover:text-white"
target="_blank"
>
upgrade your subscription
</a>
</Link>
) : (
<a
href="https://infisical.com/scheduledemo"
target="_blank"
rel="noopener noreferrer"
>
<a
className="cursor-pointer underline transition-all hover:text-white"
target="_blank"
>
upgrade your subscription
</a>
</a>
)}{" "}
to view sync logs.
</p>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,28 @@
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { Badge } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
type Props = {
secretSync: TAwsParameterStoreSync;
};
export const AwsParameterStoreSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { path, region }
} = secretSync;
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,75 @@
import { ReactNode } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GitHubSyncSelectedRepositoriesTooltipContent } from "@app/components/secret-syncs/github/GitHubSyncSelectedRepositoriesTooltipContent";
import { Tooltip } from "@app/components/v2";
import {
GitHubSyncScope,
GitHubSyncVisibility,
TGitHubSync
} from "@app/hooks/api/secretSyncs/types/github-sync";
type Props = {
secretSync: TGitHubSync;
};
export const GitHubSyncDestinationSection = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
let Components: ReactNode;
switch (destinationConfig.scope) {
case GitHubSyncScope.Organization:
Components = (
<>
<SecretSyncLabel label="Organization">{destinationConfig.org}</SecretSyncLabel>
<SecretSyncLabel label="Visibility" className="capitalize">
{destinationConfig.visibility} Repositories
</SecretSyncLabel>
{destinationConfig.visibility === GitHubSyncVisibility.Selected && (
<SecretSyncLabel label="Selected Repositories">
{destinationConfig.selectedRepositoryIds?.length ?? 0} Repositories
<Tooltip
side="bottom"
content={<GitHubSyncSelectedRepositoriesTooltipContent secretSync={secretSync} />}
>
<FontAwesomeIcon size="xs" className="ml-1 text-bunker-300" icon={faInfoCircle} />
</Tooltip>
</SecretSyncLabel>
)}
</>
);
break;
case GitHubSyncScope.Repository:
Components = (
<SecretSyncLabel label="Repository">
{destinationConfig.owner}/{destinationConfig.repo}
</SecretSyncLabel>
);
break;
case GitHubSyncScope.RepositoryEnvironment:
Components = (
<>
<SecretSyncLabel label="Repository">
{destinationConfig.owner}/{destinationConfig.repo}
</SecretSyncLabel>
<SecretSyncLabel label="Environment">{destinationConfig.env}</SecretSyncLabel>
</>
);
break;
default:
throw new Error(
`Uhandled GitHub Sync Destination Section Scope ${secretSync.destinationConfig.scope}`
);
}
return (
<>
<SecretSyncLabel className="capitalize" label="Scope">
{destinationConfig.scope.replace("-", " ")}
</SecretSyncLabel>
{Components}
</>
);
};
@@ -0,0 +1,63 @@
import { ReactNode } from "react";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection";
import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitHubSyncDestinationSection";
type Props = {
secretSync: TSecretSync;
onEditDestination: VoidFunction;
};
export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: Props) => {
const { destination, connection } = secretSync;
const app = APP_CONNECTION_MAP[connection.app].name;
let DestinationComponents: ReactNode;
switch (secretSync.destination) {
case SecretSync.AWSParameterStore:
DestinationComponents = <AwsParameterStoreSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.GitHub:
DestinationComponents = <GitHubSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Destination Configuration</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit sync destination"
onClick={onEditDestination}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="flex w-full flex-wrap gap-8">
<SecretSyncLabel label={`${app} Connection`}>{connection.name}</SecretSyncLabel>
{DestinationComponents}
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretSyncDestinatonSection";

Some files were not shown because too many files have changed in this diff Show More