Merge remote-tracking branch 'origin/main' into feat/kmip-client-management

This commit is contained in:
Sheen Capadngan
2025-02-18 02:50:12 +08:00
507 changed files with 10002 additions and 2517 deletions
@@ -0,0 +1,90 @@
import { Helmet } from "react-helmet";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, PageHeader } from "@app/components/v2";
import {
OrgPermissionAppConnectionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import {
AddAppConnectionModal,
AppConnectionsTable
} from "@app/pages/organization/AppConnections/AppConnectionsPage/components";
export const AppConnectionsPage = withPermission(
() => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const);
return (
<div className="bg-bunker-800">
<Helmet>
<title>Infisical | App Connections</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<PageHeader
className="w-full"
title={
<div className="flex w-full items-center">
<span>App Connections</span>
<a
className="-mt-1.5"
href="https://infisical.com/docs/integrations/app-connections/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 text-sm font-normal 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>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Create}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addConnection");
}}
isDisabled={!isAllowed}
className="ml-auto"
>
Add Connection
</Button>
)}
</OrgPermissionCan>
</div>
}
description="Create and configure connections with third-party apps for re-use across Infisical projects"
/>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<AppConnectionsTable />
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addConnection", isOpen)}
/>
</div>
</div>
</div>
</div>
);
},
{
action: OrgPermissionAppConnectionActions.Read,
subject: OrgPermissionSubjects.AppConnections
}
);
@@ -10,6 +10,9 @@ import { DiscriminativePick } from "@app/types";
import { AppConnectionHeader } from "../AppConnectionHeader";
import { AwsConnectionForm } from "./AwsConnectionForm";
import { AzureAppConfigurationConnectionForm } from "./AzureAppConfigurationConnectionForm";
import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
@@ -53,6 +56,12 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <GitHubConnectionForm />;
case AppConnection.GCP:
return <GcpConnectionForm onSubmit={onSubmit} />;
case AppConnection.AzureKeyVault:
return <AzureKeyVaultConnectionForm />;
case AppConnection.AzureAppConfiguration:
return <AzureAppConfigurationConnectionForm />;
case AppConnection.Databricks:
return <DatabricksConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -92,6 +101,12 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <GitHubConnectionForm appConnection={appConnection} />;
case AppConnection.GCP:
return <GcpConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.AzureKeyVault:
return <AzureKeyVaultConnectionForm appConnection={appConnection} />;
case AppConnection.AzureAppConfiguration:
return <AzureAppConfigurationConnectionForm appConnection={appConnection} />;
case AppConnection.Databricks:
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,178 @@
import crypto from "crypto";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { isInfisicalCloud } from "@app/helpers/platform";
import {
AzureAppConfigurationConnectionMethod,
TAzureAppConfigurationConnection,
useGetAppConnectionOption
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TAzureAppConfigurationConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AzureAppConfiguration),
method: z.nativeEnum(AzureAppConfigurationConnectionMethod),
tenantId: z.string().trim().optional()
});
type FormData = z.infer<typeof formSchema>;
export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => {
const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false);
const {
option: { oauthClientId },
isLoading
} = useGetAppConnectionOption(AppConnection.AzureAppConfiguration);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection
? {
...appConnection,
tenantId: appConnection.credentials.tenantId
}
: {
app: AppConnection.AzureAppConfiguration,
method: AzureAppConfigurationConnectionMethod.OAuth
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"azureAppConfigurationConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) {
case AzureAppConfigurationConnectionMethod.OAuth:
window.location.assign(
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-app-configuration`
);
break;
default:
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
}
};
let isMissingConfig: boolean;
switch (selectedMethod) {
case AzureAppConfigurationConnectionMethod.OAuth:
isMissingConfig = !oauthClientId;
break;
default:
throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`);
}
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="tenantId"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
isError={Boolean(error?.message)}
label="Tenant ID"
isOptional
errorText={error?.message}
>
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
</FormControl>
)}
/>
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.AzureAppConfiguration].name
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See documentation to configure Azure ${methodDetails.name} Connections.`
}`
: error?.message
}
isError={Boolean(error?.message) || isMissingConfig}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(AzureAppConfigurationConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting || isRedirecting}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
>
{isUpdate ? "Reconnect to Azure" : "Connect to Azure"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,178 @@
import crypto from "crypto";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { isInfisicalCloud } from "@app/helpers/platform";
import { useGetAppConnectionOption } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AzureKeyVaultConnectionMethod,
TAzureKeyVaultConnection
} from "@app/hooks/api/appConnections/types/azure-key-vault-connection";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TAzureKeyVaultConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AzureKeyVault),
method: z.nativeEnum(AzureKeyVaultConnectionMethod),
tenantId: z.string().trim().optional()
});
type FormData = z.infer<typeof formSchema>;
export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false);
const {
option: { oauthClientId },
isLoading
} = useGetAppConnectionOption(AppConnection.AzureKeyVault);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection
? {
...appConnection,
tenantId: appConnection.credentials.tenantId
}
: {
app: AppConnection.AzureKeyVault,
method: AzureKeyVaultConnectionMethod.OAuth
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"azureKeyVaultConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) {
case AzureKeyVaultConnectionMethod.OAuth:
window.location.assign(
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://vault.azure.net/.default%20openid%20offline_access&state=${state}<:>azure-key-vault`
);
break;
default:
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
}
};
let isMissingConfig: boolean;
switch (selectedMethod) {
case AzureKeyVaultConnectionMethod.OAuth:
isMissingConfig = !oauthClientId;
break;
default:
throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`);
}
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="tenantId"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
isError={Boolean(error?.message)}
label="Tenant ID"
isOptional
errorText={error?.message}
>
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
</FormControl>
)}
/>
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.AzureKeyVault].name
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See documentation to configure Azure ${methodDetails.name} Connections.`
}`
: error?.message
}
isError={Boolean(error?.message) || isMissingConfig}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(AzureKeyVaultConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting || isRedirecting}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
>
{isUpdate ? "Reconnect to Azure" : "Connect to Azure"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,166 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
DatabricksConnectionMethod,
TDatabricksConnection
} from "@app/hooks/api/appConnections/types/databricks-connection";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TDatabricksConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Databricks)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(DatabricksConnectionMethod.ServicePrincipal),
credentials: z.object({
workspaceUrl: z.string().url().trim().min(1, "Workspace URL required"),
clientId: z.string().trim().min(1, "Client ID required"),
clientSecret: z.string().trim().min(1, "Client secret required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const DatabricksConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Databricks,
method: DatabricksConnectionMethod.ServicePrincipal
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.Databricks].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(DatabricksConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.workspaceUrl"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Workspace URL"
>
<Input {...field} placeholder="https://xxx-xxxxxxxx-xxx.cloud.databricks.com" />
</FormControl>
)}
/>
<Controller
name="credentials.clientId"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Client ID"
>
<Input {...field} placeholder="05810c8f-c44d-4bd0-a327-aab6dd77719b" />
</FormControl>
)}
/>
<Controller
name="credentials.clientSecret"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Client Secret"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Databricks"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -11,9 +11,9 @@ type Props = {
};
export const AppConnectionsSelect = ({ onSelect }: Props) => {
const { isLoading, data: appConnectionOptions } = useAppConnectionOptions();
const { isPending, data: appConnectionOptions } = useAppConnectionOptions();
if (isLoading) {
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
@@ -23,7 +23,7 @@ export const AppConnectionsSelect = ({ onSelect }: Props) => {
}
return (
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-4 gap-2">
{appConnectionOptions?.map((option) => (
<button
type="button"
@@ -51,7 +51,7 @@ type AppConnectionFilters = {
};
export const AppConnectionsTable = () => {
const { isLoading, data: appConnections = [] } = useListAppConnections();
const { isPending, data: appConnections = [] } = useListAppConnections();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteConnection",
@@ -159,7 +159,7 @@ export const AppConnectionsTable = () => {
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search integrations..."
placeholder="Search connections..."
className="flex-1"
/>
<DropdownMenu>
@@ -262,7 +262,7 @@ export const AppConnectionsTable = () => {
</Tr>
</THead>
<TBody>
{isLoading && (
{isPending && (
<TableSkeleton innerKey="app-connections-table" columns={4} key="app-connections" />
)}
{filteredAppConnections.slice(offset, perPage * page).map((connection) => (
@@ -285,7 +285,7 @@ export const AppConnectionsTable = () => {
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredAppConnections?.length && (
{!isPending && !filteredAppConnections?.length && (
<EmptyState
title={
appConnections.length
@@ -44,7 +44,7 @@ export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey="confirm"
deleteKey={name}
onDeleteApproved={handleDeleteAppConnection}
/>
);
@@ -0,0 +1,23 @@
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { AppConnectionsPage } from "./AppConnectionsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/"
)({
component: AppConnectionsPage,
context: () => ({
breadcrumbs: [
{
label: "Home",
icon: () => <FontAwesomeIcon icon={faHome} />,
link: linkOptions({ to: "/organization/secret-manager/overview" })
},
{
label: "App Connections"
}
]
})
});
@@ -1,123 +0,0 @@
import { useEffect } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ContentLoader } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
GitHubConnectionMethod,
TGitHubConnection,
useCreateAppConnection,
useUpdateAppConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type FormData = Pick<TGitHubConnection, "name" | "method" | "description"> & {
returnUrl?: string;
connectionId?: string;
};
export const GitHubOAuthCallbackPage = () => {
const navigate = useNavigate();
const search = useSearch({
from: ROUTE_PATHS.Organization.AppConnections.GithubOauthCallbackPage.id
});
const updateAppConnection = useUpdateAppConnection();
const createAppConnection = useCreateAppConnection();
const { code, state, installation_id: installationId } = search;
useEffect(() => {
(async () => {
let formData: FormData;
try {
formData = JSON.parse(localStorage.getItem("githubConnectionFormData") ?? "{}") as FormData;
} catch {
createNotification({
type: "error",
text: "Invalid form state, redirecting..."
});
navigate({ to: "/" });
return;
}
// validate state
if (state !== localStorage.getItem("latestCSRFToken")) {
return;
}
localStorage.removeItem("githubConnectionFormData");
localStorage.removeItem("latestCSRFToken");
const { connectionId, name, description, returnUrl } = formData;
try {
if (connectionId) {
await updateAppConnection.mutateAsync({
app: AppConnection.GitHub,
...(installationId
? {
connectionId,
credentials: {
code: code as string,
installationId: installationId as string
}
}
: {
connectionId,
credentials: {
code: code as string
}
})
});
} else {
await createAppConnection.mutateAsync({
app: AppConnection.GitHub,
name,
description,
...(installationId
? {
method: GitHubConnectionMethod.App,
credentials: {
code: code as string,
installationId: installationId as string
}
}
: {
method: GitHubConnectionMethod.OAuth,
credentials: {
code: code as string
}
})
});
}
} catch (e: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`,
text: e.message,
type: "error"
});
navigate({
to: returnUrl ?? "/organization/settings?selectedTab=app-connections"
});
return;
}
createNotification({
text: `Successfully ${connectionId ? "updated" : "added"} GitHub Connection`,
type: "success"
});
navigate({
to: returnUrl ?? "/organization/settings?selectedTab=app-connections"
});
})();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader text="Please wait! Authentication in process." />
</div>
);
};
@@ -0,0 +1,308 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ContentLoader } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import {
AzureAppConfigurationConnectionMethod,
AzureKeyVaultConnectionMethod,
GitHubConnectionMethod,
TAzureAppConfigurationConnection,
TAzureKeyVaultConnection,
TGitHubConnection,
useCreateAppConnection,
useUpdateAppConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type BaseFormData = {
returnUrl?: string;
connectionId?: string;
};
type GithubFormData = BaseFormData & Pick<TGitHubConnection, "name" | "method" | "description">;
type AzureKeyVaultFormData = BaseFormData &
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
Pick<TAzureKeyVaultConnection["credentials"], "tenantId">;
type AzureAppConfigurationFormData = BaseFormData &
Pick<TAzureAppConfigurationConnection, "name" | "method" | "description"> &
Pick<TAzureAppConfigurationConnection["credentials"], "tenantId">;
type FormDataMap = {
[AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub };
[AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault };
[AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & {
app: AppConnection.AzureAppConfiguration;
};
};
const formDataStorageFieldMap: Partial<Record<AppConnection, string>> = {
[AppConnection.GitHub]: "githubConnectionFormData",
[AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData",
[AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData"
};
export const OAuthCallbackPage = () => {
const navigate = useNavigate();
const [isReady, setIsReady] = useState(false);
const search = useSearch({
from: ROUTE_PATHS.Organization.AppConnections.OauthCallbackPage.id
});
const rawAppConnection = useParams({
strict: false,
select: (el) => el?.appConnection as AppConnection
});
const updateAppConnection = useUpdateAppConnection();
const createAppConnection = useCreateAppConnection();
const { code, state: rawState, installation_id: installationId } = search;
const state = rawState.includes("<:>") ? rawState.split("<:>")[0] : rawState;
const appConnection = rawState.includes("<:>") ? rawState.split("<:>")[1] : rawAppConnection;
const clearState = (app: AppConnection) => {
if (state !== localStorage.getItem("latestCSRFToken")) {
throw new Error("Invalid CSRF token");
}
const dataFieldName = formDataStorageFieldMap[app];
localStorage.removeItem(dataFieldName!);
localStorage.removeItem("latestCSRFToken");
};
const getFormData = <T extends keyof FormDataMap>(app: T): FormDataMap[T] | null => {
const dataFieldName = formDataStorageFieldMap[app];
try {
const rawData = JSON.parse(localStorage.getItem(dataFieldName!) ?? "{}");
return {
...rawData,
app
} as FormDataMap[T];
} catch {
createNotification({
type: "error",
text: `Invalid ${app || ""} form state, redirecting...`
});
navigate({ to: "/" });
return null;
}
};
const handleAzureKeyVault = useCallback(async () => {
const formData = getFormData(AppConnection.AzureKeyVault);
if (formData === null) return null;
clearState(AppConnection.AzureKeyVault);
const { connectionId, name, description, returnUrl } = formData;
try {
if (connectionId) {
await updateAppConnection.mutateAsync({
app: AppConnection.AzureKeyVault,
connectionId,
credentials: {
code: code as string,
tenantId: formData.tenantId
}
});
} else {
await createAppConnection.mutateAsync({
app: AppConnection.AzureKeyVault,
name,
description,
method: AzureKeyVaultConnectionMethod.OAuth,
credentials: {
tenantId: formData.tenantId,
code: code as string
}
});
}
} catch (err: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} Azure Key Vault Connection`,
text: err?.message,
type: "error"
});
navigate({
to: returnUrl ?? "/organization/app-connections"
});
}
return {
connectionId,
returnUrl,
appConnectionName: formData.app
};
}, []);
const handleAzureAppConfiguration = useCallback(async () => {
const formData = getFormData(AppConnection.AzureAppConfiguration);
if (formData === null) return null;
clearState(AppConnection.AzureAppConfiguration);
const { connectionId, name, description, returnUrl } = formData;
try {
if (connectionId) {
await updateAppConnection.mutateAsync({
app: AppConnection.AzureAppConfiguration,
connectionId,
credentials: {
code: code as string,
tenantId: formData.tenantId
}
});
} else {
await createAppConnection.mutateAsync({
app: AppConnection.AzureAppConfiguration,
name,
description,
method: AzureAppConfigurationConnectionMethod.OAuth,
credentials: {
code: code as string,
tenantId: formData.tenantId
}
});
}
} catch (err: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} Azure App Configuration Connection`,
text: err?.message,
type: "error"
});
navigate({
to: returnUrl ?? "/organization/app-connections"
});
}
return {
connectionId,
returnUrl,
appConnectionName: formData.app
};
}, []);
const handleGithub = useCallback(async () => {
const formData = getFormData(AppConnection.GitHub);
if (formData === null) return null;
clearState(AppConnection.GitHub);
const { connectionId, name, description, returnUrl } = formData;
try {
if (connectionId) {
await updateAppConnection.mutateAsync({
app: AppConnection.GitHub,
...(installationId
? {
connectionId,
credentials: {
code: code as string,
installationId: installationId as string
}
}
: {
connectionId,
credentials: {
code: code as string
}
})
});
} else {
await createAppConnection.mutateAsync({
app: AppConnection.GitHub,
name,
description,
...(installationId
? {
method: GitHubConnectionMethod.App,
credentials: {
code: code as string,
installationId: installationId as string
}
}
: {
method: GitHubConnectionMethod.OAuth,
credentials: {
code: code as string
}
})
});
}
} catch (e: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`,
text: e.message,
type: "error"
});
navigate({
to: returnUrl ?? "/organization/app-connections"
});
}
return {
connectionId,
returnUrl,
appConnectionName: formData.app
};
}, []);
// Ensure that the localstorage is ready for use, to avoid the form data being malformed
useEffect(() => {
if (!isReady) {
setIsReady(!!localStorage.length);
}
}, [localStorage.length]);
useEffect(() => {
if (!isReady) return;
(async () => {
let data: { connectionId?: string; returnUrl?: string; appConnectionName?: string } | null =
null;
if (appConnection === AppConnection.GitHub) {
data = await handleGithub();
} else if (appConnection === AppConnection.AzureKeyVault) {
data = await handleAzureKeyVault();
} else if (appConnection === AppConnection.AzureAppConfiguration) {
data = await handleAzureAppConfiguration();
}
if (data) {
createNotification({
text: `Successfully ${data.connectionId ? "updated" : "added"} ${data.appConnectionName ? APP_CONNECTION_MAP[data.appConnectionName as AppConnection].name : ""} Connection`,
type: "success"
});
} else {
createNotification({
text: "Failed to add connection",
type: "error"
});
}
await navigate({
to: data?.returnUrl ?? "/organization/app-connections"
});
})();
}, [isReady]);
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader text="Please wait! Authentication in process." />
</div>
);
};
@@ -2,7 +2,7 @@ import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { GitHubOAuthCallbackPage } from "./GithubOauthCallbackPage";
import { OAuthCallbackPage } from "./OauthCallbackPage";
const GitHubOAuthCallbackPageQueryParamsSchema = z.object({
code: z.coerce.string().catch(""),
@@ -11,9 +11,9 @@ const GitHubOAuthCallbackPageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/github/oauth/callback"
"/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback"
)({
component: GitHubOAuthCallbackPage,
component: OAuthCallbackPage,
validateSearch: zodValidator(GitHubOAuthCallbackPageQueryParamsSchema),
search: {
middlewares: [stripSearchParams({ state: "", installation_id: "" })]
@@ -1,77 +0,0 @@
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 { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { AddAppConnectionModal, AppConnectionsTable } from "./components";
export const AppConnectionsTab = withPermission(
() => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const);
return (
<div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">App Connections</p>
<a
href="https://infisical.com/docs/integrations/app-connections/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">
Create and configure connections with third-party apps for re-use across Infisical
projects
</p>
</div>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Create}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addConnection");
}}
isDisabled={!isAllowed}
className="ml-auto"
>
Add Connection
</Button>
)}
</OrgPermissionCan>
</div>
<AppConnectionsTable />
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addConnection", isOpen)}
/>
</div>
</div>
);
},
{
action: OrgPermissionAppConnectionActions.Read,
subject: OrgPermissionSubjects.AppConnections
}
);
@@ -1 +0,0 @@
export { AppConnectionsTab } from "./AppConnectionsTab";
@@ -4,7 +4,6 @@ import { useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { AppConnectionsTab } from "../AppConnectionsTab";
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
import { ImportTab } from "../ImportTab";
import { KmipTab } from "../KmipTab/OrgKmipTab";
@@ -27,7 +26,6 @@ export const OrgTabGroup = () => {
key: "workflow-integrations",
component: OrgWorkflowIntegrationTab
},
{ name: "App Connections", key: "app-connections", component: AppConnectionsTab },
{ name: "Audit Log Streams", key: "tag-audit-log-streams", component: AuditLogStreamsTab },
{ name: "Import", key: "tab-import", component: ImportTab },
{ name: "Project Templates", key: "project-templates", component: ProjectTemplatesTab },