diff --git a/frontend-v2/public/images/integrations/Amazon Web Services.png b/frontend-v2/public/images/integrations/Amazon Web Services.png index 65b4a6ee8..d4025224e 100644 Binary files a/frontend-v2/public/images/integrations/Amazon Web Services.png and b/frontend-v2/public/images/integrations/Amazon Web Services.png differ diff --git a/frontend-v2/src/components/navigation/RegionSelect.tsx b/frontend-v2/src/components/navigation/RegionSelect.tsx index 090d5fcfc..bd916fe73 100644 --- a/frontend-v2/src/components/navigation/RegionSelect.tsx +++ b/frontend-v2/src/components/navigation/RegionSelect.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2"; +import { isInfisicalCloud } from "@app/helpers/platform"; enum Region { US = "us", @@ -80,10 +81,7 @@ export const RegionSelect = () => { }; const shouldDisplay = - window.location.origin.includes("https://app.infisical.com") || - window.location.origin.includes("https://us.infisical.com") || - window.location.origin.includes("https://eu.infisical.com") || - window.location.origin.includes("http://localhost:8080"); + isInfisicalCloud() || window.location.origin.includes("http://localhost:8080"); // only display region select for cloud if (!shouldDisplay) return null; diff --git a/frontend-v2/src/components/v2/FormControl/FormControl.tsx b/frontend-v2/src/components/v2/FormControl/FormControl.tsx index 8651422a1..4d5446519 100644 --- a/frontend-v2/src/components/v2/FormControl/FormControl.tsx +++ b/frontend-v2/src/components/v2/FormControl/FormControl.tsx @@ -44,7 +44,7 @@ export const FormLabel = ({ )} {tooltipText && ( - + )} diff --git a/frontend-v2/src/const/routes.ts b/frontend-v2/src/const/routes.ts index 5eeb5071e..35fa7e444 100644 --- a/frontend-v2/src/const/routes.ts +++ b/frontend-v2/src/const/routes.ts @@ -43,7 +43,13 @@ export const ROUTE_PATHS = Object.freeze({ RoleByIDPage: setRoute( "/organization/roles/$roleId", "/_authenticate/_inject-org-details/organization/_layout/roles/$roleId" - ) + ), + AppConnections: { + GithubOauthCallbackPage: setRoute( + "/organization/app-connections/github/oauth/callback", + "/_authenticate/_inject-org-details/organization/_layout/app-connections/github/oauth/callback" + ) + } }, SecretManager: { ApprovalPage: setRoute( diff --git a/frontend-v2/src/context/OrgPermissionContext/types.ts b/frontend-v2/src/context/OrgPermissionContext/types.ts index 41a2e7e3c..4480bbad8 100644 --- a/frontend-v2/src/context/OrgPermissionContext/types.ts +++ b/frontend-v2/src/context/OrgPermissionContext/types.ts @@ -23,7 +23,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export enum OrgPermissionAdminConsoleAction { @@ -47,6 +48,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] - | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections]; export type TOrgPermission = MongoAbility; diff --git a/frontend-v2/src/helpers/appConnections.ts b/frontend-v2/src/helpers/appConnections.ts new file mode 100644 index 000000000..9d52fb14e --- /dev/null +++ b/frontend-v2/src/helpers/appConnections.ts @@ -0,0 +1,29 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons"; + +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + AwsConnectionMethod, + GitHubConnectionMethod, + TAppConnection +} from "@app/hooks/api/appConnections/types"; + +export const APP_CONNECTION_MAP: Record = { + [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, + [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } +}; + +export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return { name: "GitHub App", icon: faGithub }; + case GitHubConnectionMethod.OAuth: + return { name: "OAuth", icon: faPassport }; + case AwsConnectionMethod.AccessKey: + return { name: "Access Key", icon: faKey }; + case AwsConnectionMethod.AssumeRole: + return { name: "Assume Role", icon: faUser }; + default: + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/frontend-v2/src/helpers/platform.ts b/frontend-v2/src/helpers/platform.ts new file mode 100644 index 000000000..821febcb8 --- /dev/null +++ b/frontend-v2/src/helpers/platform.ts @@ -0,0 +1,4 @@ +export const isInfisicalCloud = () => + window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://us.infisical.com") || + window.location.origin.includes("https://eu.infisical.com"); diff --git a/frontend-v2/src/hooks/api/appConnections/enums.ts b/frontend-v2/src/hooks/api/appConnections/enums.ts new file mode 100644 index 000000000..3c1a409a4 --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + AWS = "aws", + GitHub = "github" +} diff --git a/frontend-v2/src/hooks/api/appConnections/index.ts b/frontend-v2/src/hooks/api/appConnections/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend-v2/src/hooks/api/appConnections/mutations.tsx b/frontend-v2/src/hooks/api/appConnections/mutations.tsx new file mode 100644 index 000000000..bb8831342 --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/mutations.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections/queries"; +import { + TAppConnectionResponse, + TCreateAppConnectionDTO, + TDeleteAppConnectionDTO, + TUpdateAppConnectionDTO +} from "@app/hooks/api/appConnections/types"; + +export const useCreateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ app, ...params }: TCreateAppConnectionDTO) => { + const { data } = await apiRequest.post( + `/api/v1/app-connections/${app}`, + params + ); + + return data.appConnection; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: appConnectionKeys.list() }) + }); +}; + +export const useUpdateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app, ...params }: TUpdateAppConnectionDTO) => { + const { data } = await apiRequest.patch( + `/api/v1/app-connections/${app}/${connectionId}`, + params + ); + + return data.appConnection; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries({ queryKey: appConnectionKeys.list() }); + queryClient.invalidateQueries({ queryKey: appConnectionKeys.byId(app, connectionId) }); + } + }); +}; + +export const useDeleteAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app }: TDeleteAppConnectionDTO) => { + const { data } = await apiRequest.delete(`/api/v1/app-connections/${app}/${connectionId}`); + + return data; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries({ queryKey: appConnectionKeys.list() }); + queryClient.invalidateQueries({ queryKey: appConnectionKeys.byId(app, connectionId) }); + } + }); +}; diff --git a/frontend-v2/src/hooks/api/appConnections/queries.tsx b/frontend-v2/src/hooks/api/appConnections/queries.tsx new file mode 100644 index 000000000..624a91f0c --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/queries.tsx @@ -0,0 +1,135 @@ +import { useMemo } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TAppConnection, + TAppConnectionMap, + TAppConnectionOptions, + TGetAppConnection, + TListAppConnections +} from "@app/hooks/api/appConnections/types"; +import { + TAppConnectionOption, + TAppConnectionOptionMap +} from "@app/hooks/api/appConnections/types/app-options"; + +export const appConnectionKeys = { + all: ["app-connection"] as const, + options: () => [...appConnectionKeys.all, "options"] as const, + list: () => [...appConnectionKeys.all, "list"] as const, + listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app], + byId: (app: AppConnection, templateId: string) => + [...appConnectionKeys.all, app, "by-id", templateId] as const +}; + +export const useAppConnectionOptions = ( + options?: Omit< + UseQueryOptions< + TAppConnectionOption[], + unknown, + TAppConnectionOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/app-connections/options" + ); + + return data.appConnectionOptions; + }, + ...options + }); +}; + +export const useGetAppConnectionOption = (app: T) => { + const { data: options = [], isLoading } = useAppConnectionOptions(); + + return useMemo( + () => ({ + option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + isLoading + }), + [options, app] + ); +}; + +export const useListAppConnections = ( + options?: Omit< + UseQueryOptions< + TAppConnection[], + unknown, + TAppConnection[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.list(), + queryFn: async () => { + const { data } = + await apiRequest.get>("/api/v1/app-connections"); + + return data.appConnections; + }, + ...options + }); +}; + +export const useListAppConnectionsByApp = ( + app: T, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T][], + unknown, + TAppConnectionMap[T][], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.listByApp(app), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}` + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useGetAppConnectionById = ( + app: T, + connectionId: string, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T], + unknown, + TAppConnectionMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.byId(app, connectionId), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}/${connectionId}` + ); + + return data.appConnection; + }, + ...options + }); +}; diff --git a/frontend-v2/src/hooks/api/appConnections/types/app-options.ts b/frontend-v2/src/hooks/api/appConnections/types/app-options.ts new file mode 100644 index 000000000..bfc9e5903 --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/types/app-options.ts @@ -0,0 +1,24 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +export type TAppConnectionOptionBase = { + name: string; + methods: string[]; +}; + +export type TAwsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AWS; + accessKeyId?: string; +}; + +export type TGitHubConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.GitHub; + oauthClientId?: string; + appClientSlug?: string; +}; + +export type TAppConnectionOption = TAwsConnectionOption | TGitHubConnectionOption; + +export type TAppConnectionOptionMap = { + [AppConnection.AWS]: TAwsConnectionOption; + [AppConnection.GitHub]: TGitHubConnectionOption; +}; diff --git a/frontend-v2/src/hooks/api/appConnections/types/aws-connection.ts b/frontend-v2/src/hooks/api/appConnections/types/aws-connection.ts new file mode 100644 index 000000000..86074ca35 --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/types/aws-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + +export type TAwsConnection = TRootAppConnection & { app: AppConnection.AWS } & ( + | { + method: AwsConnectionMethod.AccessKey; + credentials: { + accessKeyId: string; + secretAccessKey: string; + }; + } + | { + method: AwsConnectionMethod.AssumeRole; + credentials: { + roleArn: string; + }; + } + ); diff --git a/frontend-v2/src/hooks/api/appConnections/types/github-connection.ts b/frontend-v2/src/hooks/api/appConnections/types/github-connection.ts new file mode 100644 index 000000000..d00936cda --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/types/github-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum GitHubConnectionMethod { + App = "github-app", + OAuth = "oauth" +} + +export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & ( + | { + method: GitHubConnectionMethod.OAuth; + credentials: { + code: string; + }; + } + | { + method: GitHubConnectionMethod.App; + credentials: { + code: string; + installationId: string; + }; + } + ); diff --git a/frontend-v2/src/hooks/api/appConnections/types/index.ts b/frontend-v2/src/hooks/api/appConnections/types/index.ts new file mode 100644 index 000000000..fcec4a1df --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/types/index.ts @@ -0,0 +1,36 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options"; +import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; +import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; + +export * from "./aws-connection"; +export * from "./github-connection"; + +export type TAppConnection = TAwsConnection | TGitHubConnection; + +export type TListAppConnections = { appConnections: T[] }; +export type TGetAppConnection = { appConnection: T }; +export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] }; +export type TAppConnectionResponse = { appConnection: TAppConnection }; + +export type TCreateAppConnectionDTO = Pick< + TAppConnection, + "name" | "credentials" | "method" | "app" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial< + Pick +> & { + connectionId: string; + app: AppConnection; +}; + +export type TDeleteAppConnectionDTO = { + app: AppConnection; + connectionId: string; +}; + +export type TAppConnectionMap = { + [AppConnection.AWS]: TAwsConnection; + [AppConnection.GitHub]: TGitHubConnection; +}; diff --git a/frontend-v2/src/hooks/api/appConnections/types/root-connection.ts b/frontend-v2/src/hooks/api/appConnections/types/root-connection.ts new file mode 100644 index 000000000..0dc4a616f --- /dev/null +++ b/frontend-v2/src/hooks/api/appConnections/types/root-connection.ts @@ -0,0 +1,9 @@ +export type TRootAppConnection = { + id: string; + name: string; + description?: string | null; + version: number; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend-v2/src/hooks/api/subscriptions/types.ts b/frontend-v2/src/hooks/api/subscriptions/types.ts index b1c4e224d..b57a2d26f 100644 --- a/frontend-v2/src/hooks/api/subscriptions/types.ts +++ b/frontend-v2/src/hooks/api/subscriptions/types.ts @@ -45,4 +45,5 @@ export type SubscriptionPlan = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: boolean; + appConnections: boolean; }; diff --git a/frontend-v2/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx b/frontend-v2/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx new file mode 100644 index 000000000..2370daa95 --- /dev/null +++ b/frontend-v2/src/pages/organization/AppConnections/GithubOauthCallbackPage/GithubOauthCallbackPage.tsx @@ -0,0 +1,128 @@ +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 & { + 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")) { + createNotification({ + type: "error", + text: "Invalid state, redirecting..." + }); + navigate({ to: "/" }); + 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 ( +
+ +
+ ); +}; diff --git a/frontend-v2/src/pages/organization/AppConnections/GithubOauthCallbackPage/route.tsx b/frontend-v2/src/pages/organization/AppConnections/GithubOauthCallbackPage/route.tsx new file mode 100644 index 000000000..c7bd3e40f --- /dev/null +++ b/frontend-v2/src/pages/organization/AppConnections/GithubOauthCallbackPage/route.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +import { GitHubOAuthCallbackPage } from "./GithubOauthCallbackPage"; + +const GitHubOAuthCallbackPageQueryParamsSchema = z.object({ + code: z.string(), + state: z.string(), + installation_id: z.string() +}); + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/organization/_layout/app-connections/github/oauth/callback" +)({ + component: GitHubOAuthCallbackPage, + validateSearch: zodValidator(GitHubOAuthCallbackPageQueryParamsSchema) +}); diff --git a/frontend-v2/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts b/frontend-v2/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts index aa8c4d7ec..56f922b52 100644 --- a/frontend-v2/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts +++ b/frontend-v2/src/pages/organization/RoleByIDPage/components/OrgRoleModifySection.utils.ts @@ -49,7 +49,8 @@ export const formSchema = z.object({ identity: generalPermissionSchema, "organization-admin-console": adminConsolePermissionSchmea, [OrgPermissionSubjects.Kms]: generalPermissionSchema, - [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema + [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, + [OrgPermissionSubjects.AppConnections]: generalPermissionSchema }) .optional() }); diff --git a/frontend-v2/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend-v2/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx index 38bf47b2b..5d359423a 100644 --- a/frontend-v2/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend-v2/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -69,7 +69,8 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "External KMS", formName: OrgPermissionSubjects.Kms }, - { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }, + { title: "App Connections", formName: OrgPermissionSubjects.AppConnections } ] as const; type Props = { diff --git a/frontend-v2/src/pages/organization/SettingsPage/SettingsPage.tsx b/frontend-v2/src/pages/organization/SettingsPage/SettingsPage.tsx index bdd2fee3d..32ee0ae1c 100644 --- a/frontend-v2/src/pages/organization/SettingsPage/SettingsPage.tsx +++ b/frontend-v2/src/pages/organization/SettingsPage/SettingsPage.tsx @@ -12,7 +12,7 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.org.title") })}
-
+

{t("settings.org.title")}

diff --git a/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx new file mode 100644 index 000000000..6f775109c --- /dev/null +++ b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx @@ -0,0 +1,97 @@ +import { + faArrowUpRightFromSquare, + faBookOpen, + faPlus, + faWrench +} 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 { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; + +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 ( +
+ +
+
+ App Connections are currently unavailable. +
+ Check back soon. +
+
+ ); + + return ( +
+
+
+
+
+

App Connections

+ +
+ + Docs + +
+
+
+

+ Create and configure connections with third-party apps for re-use across Infisical + projects +

+
+ + {(isAllowed) => ( + + )} + +
+ + handlePopUpToggle("addConnection", isOpen)} + /> +
+
+ ); + }, + { + action: OrgPermissionActions.Read, + subject: OrgPermissionSubjects.AppConnections + } +); diff --git a/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx new file mode 100644 index 000000000..c74985a3f --- /dev/null +++ b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { TAppConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { AppConnectionForm } from "./AppConnectionForm"; +import { AppConnectionsSelect } from "./AppConnectionList"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (appConnection: TAppConnection) => void; +}; + +const Content = ({ onComplete }: ContentProps) => { + const [selectedApp, setSelectedApp] = useState(null); + + if (selectedApp) { + return ( + setSelectedApp(null)} + app={selectedApp} + /> + ); + } + + return ; +}; + +export const AddAppConnectionModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx new file mode 100644 index 000000000..1032f9958 --- /dev/null +++ b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx @@ -0,0 +1,117 @@ +import { createNotification } from "@app/components/notifications"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TAppConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { DiscriminativePick } from "@app/types"; + +import { AppConnectionHeader } from "../AppConnectionHeader"; +import { AwsConnectionForm } from "./AwsConnectionForm"; +import { GitHubConnectionForm } from "./GitHubConnectionForm"; + +type FormProps = { + onComplete: (appConnection: TAppConnection) => void; +} & ({ appConnection: TAppConnection } | { app: AppConnection }); + +type CreateFormProps = FormProps & { app: AppConnection }; +type UpdateFormProps = FormProps & { + appConnection: TAppConnection; +}; + +const CreateForm = ({ app, onComplete }: CreateFormProps) => { + const createAppConnection = useCreateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[app]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await createAppConnection.mutateAsync(formData); + createNotification({ + text: `Successfully added ${appName} Connection`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to add ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + switch (app) { + case AppConnection.AWS: + return ; + case AppConnection.GitHub: + return ; + default: + throw new Error(`Unhandled App ${app}`); + } +}; + +const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { + const updateAppConnection = useUpdateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[appConnection.app]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await updateAppConnection.mutateAsync({ + connectionId: appConnection.id, + ...formData + }); + createNotification({ + text: `Successfully updated ${appName} Connection`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + switch (appConnection.app) { + case AppConnection.AWS: + return ; + case AppConnection.GitHub: + return ; + default: + throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); + } +}; + +type Props = { onBack?: () => void } & Pick & + ( + | { app: AppConnection; appConnection?: undefined } + | { app?: undefined; appConnection: TAppConnection } + ); +export const AppConnectionForm = ({ onBack, ...props }: Props) => { + const { app, appConnection } = props; + + return ( +
+ + {appConnection ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx new file mode 100644 index 000000000..d1137a887 --- /dev/null +++ b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx @@ -0,0 +1,187 @@ +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 { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TAwsConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.AWS) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: z.object({ + roleArn: z.string().trim().min(1, "Role ARN required") + }) + }), + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key required") + }) + }) +]); + +type FormData = z.infer; + +export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.AWS, + method: AwsConnectionMethod.AssumeRole + } + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + {selectedMethod === AwsConnectionMethod.AssumeRole ? ( + ( + + onChange(e.target.value)} + /> + + )} + /> + ) : ( + <> + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + + )} +
+ + + + +
+ +
+ ); +}; diff --git a/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx new file mode 100644 index 000000000..d9e25a0a7 --- /dev/null +++ b/frontend-v2/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx @@ -0,0 +1,42 @@ +import { useFormContext } from "react-hook-form"; +import { z } from "zod"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; +import { slugSchema } from "@app/lib/schemas"; + +export const genericAppConnectionFieldsSchema = z.object({ + name: slugSchema({ min: 1, max: 32, field: "Name" }), + description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() +}); + +export const GenericAppConnectionsFields = () => { + const { + register, + formState: { errors } + } = useFormContext<{ name: string; description?: string | null }>(); + + return ( + <> + + + + +