Merge pull request #2998 from Infisical/secret-syncs-feature

Feature: Secret Syncs
This commit is contained in:
Scott Wilson
2025-01-22 12:00:57 -08:00
committed by GitHub
217 changed files with 10898 additions and 620 deletions
@@ -44,11 +44,6 @@ export const GitHubOAuthCallbackPage = () => {
// validate state
if (state !== localStorage.getItem("latestCSRFToken")) {
createNotification({
type: "error",
text: "Invalid state, redirecting..."
});
navigate({ to: "/" });
return;
}
@@ -2,6 +2,7 @@
import { z } from "zod";
import { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { TPermission } from "@app/hooks/api/roles/types";
const generalPermissionSchema = z
@@ -13,6 +14,16 @@ const generalPermissionSchema = z
})
.optional();
const appConnectionsPermissionSchema = z
.object({
[OrgPermissionAppConnectionActions.Read]: z.boolean().optional(),
[OrgPermissionAppConnectionActions.Edit]: z.boolean().optional(),
[OrgPermissionAppConnectionActions.Create]: z.boolean().optional(),
[OrgPermissionAppConnectionActions.Delete]: z.boolean().optional(),
[OrgPermissionAppConnectionActions.Connect]: z.boolean().optional()
})
.optional();
const adminConsolePermissionSchmea = z
.object({
"access-all-projects": z.boolean().optional()
@@ -50,7 +61,7 @@ export const formSchema = z.object({
"organization-admin-console": adminConsolePermissionSchmea,
[OrgPermissionSubjects.Kms]: generalPermissionSchema,
[OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema,
[OrgPermissionSubjects.AppConnections]: generalPermissionSchema
"app-connections": appConnectionsPermissionSchema
})
.optional()
});
@@ -0,0 +1,186 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "../OrgRoleModifySection.utils";
type Props = {
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-access",
Custom = "custom"
}
const PERMISSION_ACTIONS = [
{ action: OrgPermissionAppConnectionActions.Read, label: "Read" },
{ action: OrgPermissionAppConnectionActions.Create, label: "Create" },
{ action: OrgPermissionAppConnectionActions.Edit, label: "Modify" },
{ action: OrgPermissionAppConnectionActions.Delete, label: "Remove" },
{ action: OrgPermissionAppConnectionActions.Connect, label: "Connect" }
] as const;
export const OrgPermissionAppConnectionRow = ({ isEditable, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: "permissions.app-connections"
});
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSION_ACTIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (score === 1 && rule?.[OrgPermissionAppConnectionActions.Read]) return Permission.ReadOnly;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
useEffect(() => {
const isRowCustom = selectedPermissionCategory === Permission.Custom;
if (isRowCustom) {
setIsRowExpanded.on();
}
}, []);
const handlePermissionChange = (val: Permission) => {
if (!val) return;
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
setIsCustom.off();
switch (val) {
case Permission.FullAccess:
setValue(
"permissions.app-connections",
{
[OrgPermissionAppConnectionActions.Read]: true,
[OrgPermissionAppConnectionActions.Edit]: true,
[OrgPermissionAppConnectionActions.Create]: true,
[OrgPermissionAppConnectionActions.Delete]: true,
[OrgPermissionAppConnectionActions.Connect]: true
},
{ shouldDirty: true }
);
break;
case Permission.ReadOnly:
setValue(
"permissions.app-connections",
{
[OrgPermissionAppConnectionActions.Read]: true,
[OrgPermissionAppConnectionActions.Edit]: false,
[OrgPermissionAppConnectionActions.Create]: false,
[OrgPermissionAppConnectionActions.Delete]: false,
[OrgPermissionAppConnectionActions.Connect]: false
},
{ shouldDirty: true }
);
break;
case Permission.NoAccess:
default:
setValue(
"permissions.app-connections",
{
[OrgPermissionAppConnectionActions.Read]: false,
[OrgPermissionAppConnectionActions.Edit]: false,
[OrgPermissionAppConnectionActions.Create]: false,
[OrgPermissionAppConnectionActions.Delete]: false,
[OrgPermissionAppConnectionActions.Connect]: false
},
{ shouldDirty: true }
);
}
};
return (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td>
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td>App Connections</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td
colSpan={3}
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && "border-mineshaft-500 p-8"}`}
>
<div className="grid grid-cols-3 gap-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.app-connections.${action}`}
key={`permissions.app-connections.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.app-connections.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -5,6 +5,7 @@ import { createNotification } from "@app/components/notifications";
import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
import { OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api";
import { OrgPermissionAppConnectionRow } from "@app/pages/organization/RoleByIDPage/components/RolePermissionsSection/OrgPermissionAppConnectionRow";
import {
formRolePermission2API,
@@ -69,8 +70,7 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "External KMS",
formName: OrgPermissionSubjects.Kms
},
{ title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates },
{ title: "App Connections", formName: OrgPermissionSubjects.AppConnections }
{ title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }
] as const;
type Props = {
@@ -165,6 +165,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
/>
);
})}
<OrgPermissionAppConnectionRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgRoleWorkspaceRow
control={control}
setValue={setValue}
@@ -1,14 +1,10 @@
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 { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
@@ -16,24 +12,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">
@@ -62,7 +42,7 @@ export const AppConnectionsTab = withPermission(
</p>
</div>
<OrgPermissionCan
I={OrgPermissionActions.Create}
I={OrgPermissionAppConnectionActions.Create}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed) => (
@@ -91,7 +71,7 @@ export const AppConnectionsTab = withPermission(
);
},
{
action: OrgPermissionActions.Read,
action: OrgPermissionAppConnectionActions.Read,
subject: OrgPermissionSubjects.AppConnections
}
);
@@ -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>
);
@@ -23,7 +23,8 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { useToggle } from "@app/hooks";
import { TAppConnection } from "@app/hooks/api/appConnections";
@@ -119,7 +120,7 @@ export const AppConnectionRow = ({
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
@@ -133,7 +134,7 @@ export const AppConnectionRow = ({
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
@@ -147,7 +148,7 @@ export const AppConnectionRow = ({
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
I={OrgPermissionAppConnectionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
@@ -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",
@@ -33,7 +33,7 @@ export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }
console.error(err);
createNotification({
text: `Failed remove ${APP_CONNECTION_MAP[app].name} connection`,
text: `Failed to remove ${APP_CONNECTION_MAP[app].name} connection`,
type: "error"
});
}
@@ -8,6 +8,7 @@ import {
import {
PermissionConditionOperators,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSecretSyncActions,
TPermissionCondition,
TPermissionConditionOperators
} from "@app/context/ProjectPermissionContext/types";
@@ -37,6 +38,16 @@ const DynamicSecretPolicyActionSchema = z.object({
[ProjectPermissionDynamicSecretActions.Lease]: z.boolean().optional()
});
const SecretSyncPolicyActionSchema = z.object({
[ProjectPermissionSecretSyncActions.Read]: z.boolean().optional(),
[ProjectPermissionSecretSyncActions.Create]: z.boolean().optional(),
[ProjectPermissionSecretSyncActions.Edit]: z.boolean().optional(),
[ProjectPermissionSecretSyncActions.Delete]: z.boolean().optional(),
[ProjectPermissionSecretSyncActions.SyncSecrets]: z.boolean().optional(),
[ProjectPermissionSecretSyncActions.ImportSecrets]: z.boolean().optional(),
[ProjectPermissionSecretSyncActions.RemoveSecrets]: z.boolean().optional()
});
const SecretRollbackPolicyActionSchema = z.object({
read: z.boolean().optional(),
create: z.boolean().optional()
@@ -137,7 +148,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]: SecretSyncPolicyActionSchema.array().default([])
})
.partial()
.optional()
@@ -331,6 +343,31 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0].delete = true;
if (canEncrypt) formVal[subject]![0].encrypt = true;
if (canDecrypt) formVal[subject]![0].decrypt = true;
return;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
const canRead = action.includes(ProjectPermissionSecretSyncActions.Read);
const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit);
const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete);
const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create);
const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets);
const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets);
const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true;
if (canEdit) formVal[subject]![0][ProjectPermissionSecretSyncActions.Edit] = true;
if (canCreate) formVal[subject]![0][ProjectPermissionSecretSyncActions.Create] = true;
if (canDelete) formVal[subject]![0][ProjectPermissionSecretSyncActions.Delete] = true;
if (canSyncSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.SyncSecrets] = true;
if (canImportSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.ImportSecrets] = true;
if (canRemoveSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true;
}
});
return formVal;
@@ -670,5 +707,23 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Perform rollback", value: "create" },
{ label: "View", value: "read" }
]
},
[ProjectPermissionSub.SecretSyncs]: {
title: "Secret Syncs",
actions: [
{ label: "Read", value: ProjectPermissionSecretSyncActions.Read },
{ label: "Create", value: ProjectPermissionSecretSyncActions.Create },
{ label: "Modify", value: ProjectPermissionSecretSyncActions.Edit },
{ label: "Remove", value: ProjectPermissionSecretSyncActions.Delete },
{ label: "Trigger Syncs", value: ProjectPermissionSecretSyncActions.SyncSecrets },
{
label: "Import Secrets from Destination",
value: ProjectPermissionSecretSyncActions.ImportSecrets
},
{
label: "Remove Secrets from Destination",
value: ProjectPermissionSecretSyncActions.RemoveSecrets
}
]
}
};
@@ -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 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>
</>
);
};
@@ -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="flex flex-col items-start justify-between py-4 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="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,17 +1,17 @@
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="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.
</p>
</div>
<div className="mx-2 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
{sortedIntegrations.map((integration) => (
<a
key={`framework-integration-${integration.slug}`}
@@ -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, PageHeader } 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="mb-8">
<PageHeader
title="Integrations"
description="Manage integrations with third-party services."
/>
<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,269 @@
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
]);
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";
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,395 @@
import { useCallback, useMemo } from "react";
import {
faBan,
faCalendarCheck,
faCheck,
faCopy,
faDownload,
faEllipsisV,
faEraser,
faInfoCircle,
faRotate,
faToggleOff,
faToggleOn,
faTrash,
faTriangleExclamation,
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 { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
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 "./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,
lastSyncMessage,
destination,
lastSyncedAt,
environment,
name,
description,
syncStatus,
isAutoSyncEnabled,
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"
)}
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>
{folder && environment ? (
<SecretSyncTableCell primaryText={folder.path} secondaryText={environment.name} />
) : (
<Td>
<Tooltip content="The source location for this sync has been deleted. Configure a new source or remove this sync.">
<div className="w-min">
<Badge
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
variant="primary"
>
<FontAwesomeIcon icon={faTriangleExclamation} />
<span>Source Folder Deleted</span>
</Badge>
</div>
</Tooltip>
</Td>
)}
<SecretSyncDestinationCol secretSync={secretSync} />
<Td>
<div className="flex items-center gap-1">
{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>
)}
{!isAutoSyncEnabled && (
<Tooltip
className="text-xs"
content="Auto-Sync is disabled. Changes to the source location will not be automatically synced to the destination."
>
<div>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
{!syncStatus && "Auto-Sync Disabled"}
</Badge>
</div>
</Tooltip>
)}
<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>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.SyncSecrets}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faRotate} />}
onClick={(e) => {
e.stopPropagation();
onTriggerSyncSecrets(secretSync);
}}
isDisabled={!isAllowed}
>
<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>
)}
</ProjectPermissionCan>
{syncOption?.canImportSecrets && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.ImportSecrets}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faDownload} />}
onClick={(e) => {
e.stopPropagation();
onTriggerImportSecrets(secretSync);
}}
isDisabled={!isAllowed}
>
<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>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.RemoveSecrets}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faEraser} />}
onClick={(e) => {
e.stopPropagation();
onTriggerRemoveSecrets(secretSync);
}}
isDisabled={!isAllowed}
>
<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>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={isAutoSyncEnabled ? faToggleOff : faToggleOn} />}
onClick={(e) => {
e.stopPropagation();
onToggleEnable(secretSync);
}}
>
{isAutoSyncEnabled ? "Disable" : "Enable"} Auto-Sync
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.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,495 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
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,
SecretSyncStatus,
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: SecretSyncStatus[];
environmentIds: string[];
};
const getSyncStatusOrderValue = (syncStatus: SecretSyncStatus | null) => {
switch (syncStatus) {
case SecretSyncStatus.Failed:
return 0;
case SecretSyncStatus.Pending:
case SecretSyncStatus.Running:
return 1;
case SecretSyncStatus.Succeeded:
return 2;
default:
return 3;
}
};
type Props = {
secretSyncs: TSecretSync[];
};
const STATUS_ICON_MAP = {
[SecretSyncStatus.Succeeded]: { icon: faCheck, className: "text-green", name: "Synced" },
[SecretSyncStatus.Failed]: { icon: faWarning, className: "text-red", name: "Not Synced" },
[SecretSyncStatus.Pending]: { icon: faRotate, className: "text-yellow", name: "Syncing" },
[SecretSyncStatus.Running]: { icon: faRotate, className: "text-yellow", name: "Syncing" }
};
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 } = secretSync;
if (filters.destinations.length && !filters.destinations.includes(destination))
return false;
if (
filters.environmentIds.length &&
environment?.id &&
!filters.environmentIds.includes(environment.id)
)
return false;
if (filters.status.length && (!syncStatus || !filters.status.includes(syncStatus))) {
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:
if (!syncOne.syncStatus && syncTwo.syncStatus) return 1;
if (syncOne.syncStatus && !syncTwo.syncStatus) return -1;
if (!syncOne.syncStatus && !syncTwo.syncStatus) return 0;
return (
getSyncStatusOrderValue(syncOne.syncStatus) -
getSyncStatusOrderValue(syncTwo.syncStatus)
);
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 isAutoSyncEnabled = !secretSync.isAutoSyncEnabled;
try {
await updateSync.mutateAsync({
syncId: secretSync.id,
destination: secretSync.destination,
isAutoSyncEnabled
});
createNotification({
text: `Successfully ${isAutoSyncEnabled ? "enabled" : "disabled"} auto-sync for ${destinationName} Sync`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${isAutoSyncEnabled ? "enable" : "disable"} auto-sync for ${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(SecretSyncStatus).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-[10.5rem]">
<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,84 @@
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 { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
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: 4000
}
);
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={ProjectPermissionSecretSyncActions.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,11 +1,22 @@
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
.nativeEnum(IntegrationsListPageTabs)
.catch(IntegrationsListPageTabs.NativeIntegrations)
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/"
)({
component: IntegrationsListPage,
validateSearch: zodValidator(IntegrationsListPageQuerySchema),
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
@@ -0,0 +1,154 @@
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 { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
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={ProjectPermissionSecretSyncActions.Read}
a={ProjectPermissionSub.SecretSyncs}
>
<PageContent />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,312 @@
import { useCallback } from "react";
import {
faBan,
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 {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
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 isAutoSyncEnabled = !secretSync.isAutoSyncEnabled;
try {
await updateSync.mutateAsync({
syncId: secretSync.id,
destination: secretSync.destination,
isAutoSyncEnabled
});
createNotification({
text: `Successfully ${isAutoSyncEnabled ? "enabled" : "disabled"} auto-sync for ${destinationName} Sync`,
type: "success"
});
} catch {
createNotification({
text: `Failed to ${isAutoSyncEnabled ? "enable" : "disable"} auto-sync for ${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} />
{secretSync.isAutoSyncEnabled ? (
<Badge
variant="success"
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faRotate} />
<span>Auto-Sync Enabled</span>
</Badge>
) : (
<Tooltip
className="text-xs"
content="Auto-Sync is disabled. Changes to the source location will not be automatically synced to the destination."
>
<div>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faBan} />
<span>Auto-Sync Disabled</span>
</Badge>
</div>
</Tooltip>
)}
<div>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.SyncSecrets}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faRotate} />}
onClick={handleTriggerSync}
className="h-9 rounded-r-none bg-mineshaft-500"
isDisabled={!isAllowed}
>
Trigger Sync
</Button>
)}
</ProjectPermissionCan>
<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 && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.ImportSecrets}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faDownload} />}
onClick={() => handlePopUpOpen("importSecrets")}
isDisabled={!isAllowed}
>
<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>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.RemoveSecrets}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faEraser} />}
onClick={() => handlePopUpOpen("removeSecrets")}
isDisabled={!isAllowed}
>
<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>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={
<FontAwesomeIcon
icon={secretSync.isAutoSyncEnabled ? faToggleOff : faToggleOn}
/>
}
onClick={handleToggleEnableSync}
>
{secretSync.isAutoSyncEnabled ? "Disable" : "Enable"} Auto-Sync
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.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,85 @@
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";
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";
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,64 @@
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 { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
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={ProjectPermissionSecretSyncActions.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";
@@ -0,0 +1,74 @@
import { useMemo } from "react";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
import { SecretSyncStatus, TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
onEditDetails: VoidFunction;
};
export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) => {
const { syncStatus, lastSyncMessage, lastSyncedAt, name, description } = secretSync;
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]);
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">Details</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit sync details"
onClick={onEditDetails}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-3">
<SecretSyncLabel label="Name">{name}</SecretSyncLabel>
<SecretSyncLabel label="Description">{description}</SecretSyncLabel>
{lastSyncedAt && (
<SecretSyncLabel label="Last Synced">
{format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")}
</SecretSyncLabel>
)}
{syncStatus === SecretSyncStatus.Failed && failureMessage && (
<SecretSyncLabel labelClassName="text-red" label="Last Sync Error">
<p className="break-words rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</p>
</SecretSyncLabel>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,57 @@
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
// onEditOptions: VoidFunction;
};
export const SecretSyncOptionsSection = ({
secretSync
// onEditOptions
}: Props) => {
const {
destination,
syncOptions: {
// appendSuffix,
// prependPrefix,
initialSyncBehavior
}
} = secretSync;
return (
<div>
<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">Sync Options</h3>
{/* <ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit sync options"
onClick={onEditOptions}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan> */}
</div>
<div>
<div className="space-y-3">
<SecretSyncLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name}
</SecretSyncLabel>
{/* <SecretSyncLabel label="Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Suffix">{appendSuffix}</SecretSyncLabel> */}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,65 @@
import { faEdit, faTriangleExclamation } 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 { Badge, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
onEditSource: VoidFunction;
};
export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) => {
const { folder, environment } = secretSync;
return (
<div>
<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">Source</h3>
<div>
{(!folder || !environment) && (
<Tooltip content="The source location for this sync has been deleted. Configure a new source or remove this sync.">
<div className="mr-1 inline-block w-min">
<Badge
className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap"
variant="primary"
>
<FontAwesomeIcon icon={faTriangleExclamation} />
<span>Folder Deleted</span>
</Badge>
</div>
</Tooltip>
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit sync source"
onClick={onEditSource}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<div className="space-y-3">
<SecretSyncLabel label="Environment">{environment?.name}</SecretSyncLabel>
<SecretSyncLabel label="Path">{folder?.path}</SecretSyncLabel>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,6 @@
export * from "./SecretSyncActionTriggers";
export * from "./SecretSyncAuditLogsSection";
export * from "./SecretSyncDestinationSection";
export * from "./SecretSyncDetailsSection";
export * from "./SecretSyncOptionsSection";
export * from "./SecretSyncSourceSection";
@@ -0,0 +1,26 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { SecretSyncDetailsByIDPage } from "./SecretSyncDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId"
)({
component: SecretSyncDetailsByIDPage,
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Integrations",
link: linkOptions({
to: "/secret-manager/$projectId/integrations",
params
})
},
{
label: "Secret Sync"
}
]
};
}
});