diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 3f48a39d46..7dfda4bee9 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -343,6 +343,66 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + url: "/:integrationAuthId/github/orgs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + orgs: z.object({ name: z.string(), orgId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const orgs = await server.services.integrationAuth.getGithubOrgs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + if (!orgs) throw new Error("No organization found."); + + return { orgs }; + } + }); + + server.route({ + url: "/:integrationAuthId/github/envs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + repoOwner: z.string().trim(), + repoName: z.string().trim() + }), + response: { + 200: z.object({ + envs: z.object({ name: z.string(), envId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const envs = await server.services.integrationAuth.getGithubEnvs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId, + repoName: req.query.repoName, + repoOwner: req.query.repoOwner + }); + if (!envs) throw new Error("No organization found."); + + return { envs }; + } + }); + server.route({ url: "/:integrationAuthId/qovery/orgs", method: "GET", diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 4cd0bc159d..35ff27a9af 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { Octokit } from "@octokit/rest"; import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -24,6 +25,8 @@ import { TIntegrationAuthAppsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthGithubEnvsDTO, + TIntegrationAuthGithubOrgsDTO, TIntegrationAuthHerokuPipelinesDTO, TIntegrationAuthNorthflankSecretGroupDTO, TIntegrationAuthQoveryEnvironmentsDTO, @@ -383,6 +386,72 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getGithubOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthGithubOrgsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const octokit = new Octokit({ + auth: accessToken + }); + + const { data } = await octokit.request("GET /user/orgs", { + headers: { + "X-GitHub-Api-Version": "2022-11-28" + } + }); + if (!data) return []; + + return data.map(({ login: name, id: orgId }) => ({ name, orgId: String(orgId) })); + }; + + const getGithubEnvs = async ({ + actorId, + actor, + actorOrgId, + id, + repoOwner, + repoName + }: TIntegrationAuthGithubEnvsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const octokit = new Octokit({ + auth: accessToken + }); + + const { + data: { environments } + } = await octokit.request("GET /repos/{owner}/{repo}/environments", { + headers: { + "X-GitHub-Api-Version": "2022-11-28" + }, + owner: repoOwner, + repo: repoName + }); + if (!environments) return []; + return environments.map(({ id: envId, name }) => ({ name, envId: String(envId) })); + }; + const getQoveryOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthQoveryOrgsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); @@ -756,9 +825,7 @@ export const integrationAuthServiceFactory = ({ while (hasNextPage) { // eslint-disable-next-line - const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get( - workspaceUrl, - { + const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get(workspaceUrl, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -934,6 +1001,8 @@ export const integrationAuthServiceFactory = ({ getIntegrationApps, getVercelBranches, getApps, + getGithubOrgs, + getGithubEnvs, getChecklyGroups, getQoveryApps, getQoveryEnvs, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index e3dbc83418..0c8671fbfe 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -44,6 +44,16 @@ export type TIntegrationAuthChecklyGroupsDTO = { accountId: string; } & Omit; +export type TIntegrationAuthGithubOrgsDTO = { + id: string; +} & Omit; + +export type TIntegrationAuthGithubEnvsDTO = { + id: string; + repoName: string; + repoOwner: string; +} & Omit; + export type TIntegrationAuthQoveryOrgsDTO = { id: string; } & Omit; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 0988aa9203..43ae2dc821 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1110,98 +1110,176 @@ const syncSecretsGitHub = async ({ interface GitHubRepoKey { key_id: string; key: string; + id?: number | undefined; + url?: string | undefined; + title?: string | undefined; + created_at?: string | undefined; } interface GitHubSecret { name: string; created_at: string; updated_at: string; - } - - interface GitHubSecretRes { - [index: string]: GitHubSecret; + visibility?: "all" | "private" | "selected"; + selected_repositories_url?: string | undefined; } const octokit = new Octokit({ auth: accessToken }); - // const user = (await octokit.request('GET /user', {})).data; - const repoPublicKey: GitHubRepoKey = ( - await octokit.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", { - owner: integration.owner as string, - repo: integration.app as string - }) - ).data; - - // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key - let encryptedSecrets: GitHubSecretRes = ( - await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { - owner: integration.owner as string, - repo: integration.app as string - }) - ).data.secrets.reduce( - (obj, secret) => ({ - ...obj, - [secret.name]: secret - }), - {} - ); + enum GithubScope { + Repo = "github-repo", + Org = "github-org", + Env = "github-env" + } - encryptedSecrets = Object.keys(encryptedSecrets).reduce( - ( - result: { - [key: string]: GitHubSecret; - }, - key - ) => { - if ( - (appendices?.prefix !== undefined ? key.startsWith(appendices?.prefix) : true) && - (appendices?.suffix !== undefined ? key.endsWith(appendices?.suffix) : true) - ) { - result[key] = encryptedSecrets[key]; - } - return result; - }, - {} - ); + let repoPublicKey: GitHubRepoKey; - await Promise.all( - Object.keys(encryptedSecrets).map(async (key) => { - if (!(key in secrets)) { - return octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: integration.owner as string, - repo: integration.app as string, - secret_name: key - }); - } - }) - ); + switch (integration.scope) { + case GithubScope.Org: { + const { data } = await octokit.request("GET /orgs/{org}/actions/secrets/public-key", { + org: integration.owner as string + }); + repoPublicKey = data; + break; + } + case GithubScope.Env: { + const { data } = await octokit.request( + "GET /repositories/{repository_id}/environments/{environment_name}/secrets/public-key", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string + } + ); + repoPublicKey = data; + break; + } + default: { + const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", { + owner: integration.owner as string, + repo: integration.app as string + }); + repoPublicKey = data; + break; + } + } - await Promise.all( - Object.keys(secrets).map((key) => { - // let encryptedSecret; - return sodium.ready.then(async () => { - // convert secret & base64 key to Uint8Array. - const binkey = sodium.from_base64(repoPublicKey.key, sodium.base64_variants.ORIGINAL); - const binsec = sodium.from_string(secrets[key].value); + // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key + let encryptedSecrets: GitHubSecret[]; - // encrypt secret using libsodium - const encBytes = sodium.crypto_box_seal(binsec, binkey); + switch (integration.scope) { + case GithubScope.Org: { + encryptedSecrets = ( + await octokit.request("GET /orgs/{org}/actions/secrets", { + org: integration.owner as string + }) + ).data.secrets; + break; + } + case GithubScope.Env: { + encryptedSecrets = ( + await octokit.request("GET /repositories/{repository_id}/environments/{environment_name}/secrets", { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string + }) + ).data.secrets; + break; + } + default: { + encryptedSecrets = ( + await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { + owner: integration.owner as string, + repo: integration.app as string + }) + ).data.secrets; + break; + } + } - // convert encrypted Uint8Array to base64 - const encryptedSecret = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL); + for await (const encryptedSecret of encryptedSecrets) { + if ( + !(encryptedSecret.name in secrets) && + !(appendices?.prefix !== undefined && !encryptedSecret.name.startsWith(appendices?.prefix)) && + !(appendices?.suffix !== undefined && !encryptedSecret.name.endsWith(appendices?.suffix)) + ) { + switch (integration.scope) { + case GithubScope.Org: { + await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, + secret_name: encryptedSecret.name + }); + break; + } + case GithubScope.Env: { + await octokit.request( + "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: encryptedSecret.name + } + ); + break; + } + default: { + await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: encryptedSecret.name + }); + break; + } + } + } + } - await octokit.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: integration.owner as string, - repo: integration.app as string, - secret_name: key, - encrypted_value: encryptedSecret, - key_id: repoPublicKey.key_id - }); - }); - }) - ); + await sodium.ready.then(async () => { + for await (const key of Object.keys(secrets)) { + // convert secret & base64 key to Uint8Array. + const binkey = sodium.from_base64(repoPublicKey.key, sodium.base64_variants.ORIGINAL); + const binsec = sodium.from_string(secrets[key].value); + + // encrypt secret using libsodium + const encBytes = sodium.crypto_box_seal(binsec, binkey); + + // convert encrypted Uint8Array to base64 + const encryptedSecret = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL); + + switch (integration.scope) { + case GithubScope.Org: + await octokit.request("PUT /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, + secret_name: key, + visibility: "all", + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + }); + break; + case GithubScope.Env: + await octokit.request( + "PUT /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: key, + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + } + ); + break; + default: + await octokit.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: key, + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + }); + break; + } + } + }); }; /** diff --git a/docs/images/integrations/github/integrations-github-scope-env.png b/docs/images/integrations/github/integrations-github-scope-env.png new file mode 100644 index 0000000000..e38874bd38 Binary files /dev/null and b/docs/images/integrations/github/integrations-github-scope-env.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-org.png b/docs/images/integrations/github/integrations-github-scope-org.png new file mode 100644 index 0000000000..d5ef76a2bf Binary files /dev/null and b/docs/images/integrations/github/integrations-github-scope-org.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-repo.png b/docs/images/integrations/github/integrations-github-scope-repo.png new file mode 100644 index 0000000000..353527c788 Binary files /dev/null and b/docs/images/integrations/github/integrations-github-scope-repo.png differ diff --git a/docs/images/integrations/github/integrations-github.png b/docs/images/integrations/github/integrations-github.png index dccc42c0db..38550b4668 100644 Binary files a/docs/images/integrations/github/integrations-github.png and b/docs/images/integrations/github/integrations-github.png differ diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 95fe53ece5..10caabc2f3 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -3,17 +3,14 @@ title: "GitHub Actions" description: "How to sync secrets from Infisical to GitHub Actions" --- - - - - Infisical can sync secrets to GitHub repo secrets only. If your repo uses environment secrets, then stay tuned with this [issue](https://github.com/Infisical/infisical/issues/54). - +Infisical lets you sync secrets to GitHub at the organization-level, repository-level, and repository environment-level. - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - Ensure you have admin privileges to the repo you want to sync secrets to. +Prerequisites: +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) +- Ensure that you have admin privileges to the repository you want to sync secrets to. + + Navigate to your project's integrations tab in Infisical. @@ -29,12 +26,27 @@ description: "How to sync secrets from Infisical to GitHub Actions" Although this step breaks E2EE, it's necessary for Infisical to sync the environment variables to the cloud platform. - - Select which Infisical environment secrets you want to sync to which GitHub repo and press start integration to start syncing secrets to the repo. + + Select which Infisical environment secrets you want to sync to which GitHub organization, repository, or repository environment. + + + + ![integrations github](../../images/integrations/github/integrations-github-scope-repo.png) + + + ![integrations github](../../images/integrations/github/integrations-github-scope-org.png) + + + ![integrations github](../../images/integrations/github/integrations-github-scope-env.png) + + + + Finally, press create integration to start syncing secrets to GitHub. ![integrations github](../../images/integrations/github/integrations-github.png) + Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub @@ -45,13 +57,13 @@ description: "How to sync secrets from Infisical to GitHub Actions" ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) - ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` and the **Authorization callback URL** to `https://your-domain.com/integrations/github/oauth2/callback`. - ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) - + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) + If you have a GitHub organization, you can create an OAuth application under it in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. @@ -59,17 +71,17 @@ description: "How to sync secrets from Infisical to GitHub Actions" Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. - - ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) - + + ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) + Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: - `CLIENT_ID_GITHUB`: The **Client ID** of your GitHub OAuth application. - `CLIENT_SECRET_GITHUB`: The **Client Secret** of your GitHub OAuth application. - + Once added, restart your Infisical instance and use the GitHub integration. + - diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index e70ffdccb7..545985fa90 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -6,6 +6,8 @@ export { useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, + useGetIntegrationAuthGithubEnvs, + useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthNorthflankSecretGroups, useGetIntegrationAuthRailwayEnvironments, useGetIntegrationAuthRailwayServices, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index d87bb66c82..fda7e322d9 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -39,6 +39,10 @@ const integrationAuthKeys = { integrationAuthId: string; accountId: string; }) => [{ integrationAuthId, accountId }, "integrationAuthChecklyGroups"] as const, + getIntegrationAuthGithubOrgs: (integrationAuthId: string) => + [{ integrationAuthId }, "integrationAuthGithubOrgs"] as const, + getIntegrationAuthGithubEnvs: (integrationAuthId: string, repoName: string, repoOwner: string) => + [{ integrationAuthId, repoName, repoOwner }, "integrationAuthGithubOrgs"] as const, getIntegrationAuthQoveryOrgs: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const, getIntegrationAuthQoveryProjects: ({ @@ -177,6 +181,32 @@ const fetchIntegrationAuthVercelBranches = async ({ return branches; }; +const fetchIntegrationAuthGithubOrgs = async (integrationAuthId: string) => { + const { + data: { orgs } + } = await apiRequest.get<{ orgs: Org[] }>( + `/api/v1/integration-auth/${integrationAuthId}/github/orgs` + ); + + return orgs; +}; + +const fetchIntegrationAuthGithubEnvs = async ( + integrationAuthId: string, + repoName: string, + repoOwner: string +) => { + if (!repoName || !repoOwner) return []; + + const { + data: { envs } + } = await apiRequest.get<{ envs: Array<{ name: string; envId: string }> }>( + `/api/v1/integration-auth/${integrationAuthId}/github/envs?repoName=${repoName}&repoOwner=${repoOwner}` + ); + + return envs; +}; + const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => { const { data: { orgs } @@ -301,8 +331,6 @@ const fetchIntegrationAuthHerokuPipelines = async ({ integrationAuthId }: { `/api/v1/integration-auth/${integrationAuthId}/heroku/pipelines` ); - console.log(99999, pipelines) - return pipelines; }; @@ -482,6 +510,30 @@ export const useGetIntegrationAuthChecklyGroups = ({ }); }; +export const useGetIntegrationAuthGithubOrgs = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthGithubOrgs(integrationAuthId), + queryFn: () => fetchIntegrationAuthGithubOrgs(integrationAuthId), + enabled: true + }); +}; + +export const useGetIntegrationAuthGithubEnvs = ( + integrationAuthId: string, + repoName: string, + repoOwner: string +) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthGithubEnvs( + integrationAuthId, + repoName, + repoOwner + ), + queryFn: () => fetchIntegrationAuthGithubEnvs(integrationAuthId, repoName, repoOwner), + enabled: true + }); +}; + export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthQoveryOrgs(integrationAuthId), diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 73db6c07d9..a67ce15a85 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -11,22 +11,21 @@ export type TCloudIntegration = { export type TIntegration = { id: string; - projectId: string; - envId: string; - environment: { slug: string; name: string; id: string }; isActive: boolean; - url: any; - app: string; - appId: string; - targetEnvironment: string; - targetEnvironmentId: string; - targetService: string; - targetServiceId: string; - owner: string; - path: string; - region: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + owner?: string; + path?: string; + region?: string; + scope?: string; integration: string; - integrationAuth: string; + integrationAuthId: string; + envId: string; secretPath: string; createdAt: string; updatedAt: string; @@ -45,4 +44,4 @@ export enum IntegrationSyncBehavior { OVERWRITE_TARGET = "overwrite-target", PREFER_TARGET = "prefer-target", PREFER_SOURCE = "prefer-source" -} \ No newline at end of file +} diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 23296cefd2..c1e2073c10 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; @@ -12,11 +13,14 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import axios from "axios"; import { motion } from "framer-motion"; import queryString from "query-string"; +import { twMerge } from "tailwind-merge"; +import * as yup from "yup"; -import { useCreateIntegration } from "@app/hooks/api"; - +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, Card, @@ -33,263 +37,547 @@ import { TabList, TabPanel, Tabs -} from "../../../components/v2"; +} from "@app/components/v2"; import { + useCreateIntegration, useGetIntegrationAuthApps, - useGetIntegrationAuthById -} from "../../../hooks/api/integrationAuth"; -import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + useGetIntegrationAuthById, + useGetIntegrationAuthGithubEnvs, + useGetIntegrationAuthGithubOrgs, + useGetWorkspaceById +} from "@app/hooks/api"; enum TabSections { Connection = "connection", Options = "options" } +const targetEnv = ["github-repo", "github-org", "github-env"] as const; +type TargetEnv = (typeof targetEnv)[number]; + +const schema = yup.object({ + selectedSourceEnvironment: yup.string().trim().required("Project Environment is required"), + secretPath: yup.string().trim().required("Secrets Path is required"), + secretSuffix: yup.string().trim().optional(), + + scope: yup.mixed().oneOf(targetEnv.slice()).required(), + + repoIds: yup.mixed().when("scope", { + is: "github-repo", + then: yup.array(yup.string().required()).min(1, "Select at least one repositories") + }), + + repoId: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Repository is required") + }), + + repoName: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Repository is required") + }), + + repoOwner: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Repository is required") + }), + + envId: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Environment is required") + }), + + orgId: yup.mixed().when("scope", { + is: "github-org", + then: yup.string().required("Organization is required") + }) +}); + +type FormData = yup.InferType; + export default function GitHubCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); + const { createNotification } = useNotificationContext(); - const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + const integrationAuthId = + (queryString.parse(router.asPath.split("?")[1]).integrationAuthId as string) ?? ""; const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); - const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId); + const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({ - integrationAuthId: (integrationAuthId as string) ?? "" + integrationAuthId }); - const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); - const [secretPath, setSecretPath] = useState("/"); - const [targetAppIds, setTargetAppIds] = useState([]); - const [secretSuffix, setSecretSuffix] = useState(""); + const { data: integrationAuthOrgs } = useGetIntegrationAuthGithubOrgs( + integrationAuthId as string + ); + + const { control, handleSubmit, watch, setValue } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + secretPath: "/", + scope: "github-repo", + repoIds: [] + } + }); + + const scope = watch("scope"); + const repoId = watch("repoId"); + const repoIds = watch("repoIds"); + const repoName = watch("repoName"); + const repoOwner = watch("repoOwner"); + + const { data: integrationAuthGithubEnvs } = useGetIntegrationAuthGithubEnvs( + integrationAuthId as string, + repoName, + repoOwner + ); const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (workspace) { - setSelectedSourceEnvironment(workspace.environments[0].slug); + setValue("selectedSourceEnvironment", workspace.environments[0].slug); } }, [workspace]); useEffect(() => { - if (integrationAuthApps) { - if (integrationAuthApps.length > 0) { - setTargetAppIds([String(integrationAuthApps[0].appId)]); - } else { - setTargetAppIds(["none"]); - } + if (integrationAuthGithubEnvs && integrationAuthGithubEnvs?.length > 0) { + setValue("envId", integrationAuthGithubEnvs[0].envId); + } else { + setValue("envId", undefined); } - }, [integrationAuthApps]); + }, [integrationAuthGithubEnvs]); - const handleButtonClick = async () => { + const onFormSubmit = async (data: FormData) => { try { setIsLoading(true); if (!integrationAuth?.id) return; - const targetApps = integrationAuthApps?.filter((integrationAuthApp) => - targetAppIds.includes(String(integrationAuthApp.appId)) - ); + switch (data.scope) { + case "github-repo": { + const targetApps = integrationAuthApps?.filter((integrationAuthApp) => + data.repoIds?.includes(String(integrationAuthApp.appId)) + ); - if (!targetApps) return; + if (!targetApps) return; - await Promise.all( - targetApps.map(async (targetApp) => { + await Promise.all( + targetApps.map(async (targetApp) => { + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + scope: data.scope, + secretPath: data.secretPath, + sourceEnvironment: data.selectedSourceEnvironment, + app: targetApp.name, // repo name + owner: targetApp.owner, // repo owner + metadata: { + secretSuffix: data.secretSuffix + } + }); + }) + ); + + break; + } + case "github-org": await mutateAsync({ integrationAuthId: integrationAuth?.id, isActive: true, - app: targetApp.name, - sourceEnvironment: selectedSourceEnvironment, - owner: targetApp.owner, - secretPath, + secretPath: data.secretPath, + sourceEnvironment: data.selectedSourceEnvironment, + scope: data.scope, + owner: integrationAuthOrgs?.find((e) => e.orgId === data.orgId)?.name, metadata: { - secretSuffix + secretSuffix: data.secretSuffix } }); - }) - ); + break; + + case "github-env": + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + secretPath: data.secretPath, + sourceEnvironment: data.selectedSourceEnvironment, + scope: data.scope, + app: repoName, + appId: data.repoId, + owner: repoOwner, + targetEnvironmentId: data.envId, + metadata: { + secretSuffix: data.secretSuffix + } + }); + break; + default: + throw new Error("Invalid scope"); + } setIsLoading(false); router.push(`/integrations/${localStorage.getItem("projectData.id")}`); } catch (err) { console.error(err); + + let errorMessage: string = "Something went wrong!"; + if (axios.isAxiosError(err)) { + const { message } = err?.response?.data as { message: string }; + errorMessage = message; + } + + createNotification({ + text: errorMessage, + type: "error" + }); + setIsLoading(false); } }; - return integrationAuth && - workspace && - selectedSourceEnvironment && - integrationAuthApps && - targetAppIds ? ( -
+ return integrationAuth && workspace && integrationAuthApps ? ( +
Set Up GitHub Integration - -
-
- GitHub logo -
- GitHub Integration - - -
- - Docs - -
-
- -
-
- - -
- Connection - Options +
+ +
+
+ GitHub logo +
+ GitHub Integration + + +
+ + Docs + +
+
+
- - - - - - - - setSecretPath(evt.target.value)} - placeholder="Provide a path, default is /" + + + )} /> - - - - - {integrationAuthApps.length > 0 ? ( -
- {targetAppIds.length === 1 - ? integrationAuthApps?.find( - (integrationAuthApp) => - targetAppIds[0] === String(integrationAuthApp.appId) - )?.name - : `${targetAppIds.length} repositories selected`} - -
- ) : ( -
- No repositories found -
- )} -
- - {integrationAuthApps.length > 0 ? ( - integrationAuthApps.map((integrationAuthApp) => { - const isSelected = targetAppIds.includes(String(integrationAuthApp.appId)); - - return ( - { - if (targetAppIds.includes(String(integrationAuthApp.appId))) { - setTargetAppIds( - targetAppIds.filter( - (appId) => appId !== String(integrationAuthApp.appId) - ) - ); - } else { - setTargetAppIds([ - ...targetAppIds, - String(integrationAuthApp.appId) - ]); - } - }} - key={integrationAuthApp.appId} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" + ( + + + + )} + /> + ( + + + + )} + /> + {scope === "github-repo" && ( + ( + + + + {integrationAuthApps.length > 0 ? ( +
+ {repoIds.length === 1 + ? integrationAuthApps?.reduce( + (acc, { appId, name, owner }) => + repoIds[0] === appId ? `${owner}/${name}` : acc, + "" + ) + : `${repoIds.length} repositories selected`} + +
+ ) : ( +
+ No repositories found +
+ )} +
+ - {integrationAuthApp.name} - - ); - }) - ) : ( -
+ {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => { + const isSelected = repoIds.includes( + String(integrationAuthApp.appId) + ); + + return ( + { + if (repoIds.includes(String(integrationAuthApp.appId))) { + onChange( + repoIds.filter( + (appId: string) => + appId !== String(integrationAuthApp.appId) + ) + ); + } else { + onChange([...repoIds, String(integrationAuthApp.appId)]); + } + }} + key={`repos-id-${integrationAuthApp.appId}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {integrationAuthApp.owner}/{integrationAuthApp.name} + + ); + }) + ) : ( +
+ )} + + + )} - - - - - - - - - setSecretSuffix(evt.target.value)} - placeholder="Provide a suffix for secret names, default is no suffix" + /> + )} + {scope === "github-org" && ( + ( + + + + )} + /> + )} + {scope === "github-env" && ( + ( + + + + )} + /> + )} + {scope === "github-env" && ( + ( + + + + )} + /> + )} + + + + + ( + + + + )} /> - - - - - + + + +
+ +
+
@@ -318,7 +606,7 @@ export default function GitHubCreateIntegrationPage() { /> ) : (
- +

Something went wrong. Please contact{" "} link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; break; case "github": - link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; + link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; break; case "gitlab": link = `${window.location.origin}/integrations/gitlab/authorize`; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 26e7fcd60c..714817ad1f 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -9,11 +9,8 @@ import { AlertDescription, DeleteActionModal, EmptyState, - FormControl, FormLabel, IconButton, - Select, - SelectItem, Skeleton, Tooltip } from "@app/components/v2"; @@ -22,7 +19,7 @@ import { usePopUp } from "@app/hooks"; import { TIntegration } from "@app/hooks/api/types"; type Props = { - environments: Array<{ name: string; slug: string }>; + environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; isLoading?: boolean; onIntegrationDelete: (integration: TIntegration, cb: () => void) => void; @@ -80,29 +77,15 @@ export const IntegrationsSection = ({

{integrations?.map((integration) => (
-
- - - +
+ +
+ {environments.find((e) => e.id === integration.envId)?.name || "-"} +
@@ -142,11 +125,22 @@ export const IntegrationsSection = ({
)}
- -
- {integration.integration === "hashicorp-vault" - ? `${integration.app} - path: ${integration.path}` - : integration.app} + +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app}
{(integration.integration === "vercel" || @@ -154,32 +148,31 @@ export const IntegrationsSection = ({ integration.integration === "railway" || integration.integration === "gitlab" || integration.integration === "teamcity" || - integration.integration === "bitbucket") && ( + integration.integration === "bitbucket" || + (integration.integration === "github" && integration.scope === "github-env")) && (
+
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "checkly" && integration.targetService && ( +
+
- {integration.targetEnvironment} + {integration.targetService}
)} {(integration.integration === "checkly" || integration.integration === "github") && ( - <> - {integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} -
- -
- {integration?.metadata?.secretSuffix || "-"} -
+
+ +
+ {integration?.metadata?.secretSuffix || "-"}
- +
)}
@@ -214,7 +207,7 @@ export const IntegrationsSection = ({ (popUp?.deleteConfirmation.data as TIntegration)?.integration || " " } integration for ${(popUp?.deleteConfirmation.data as TIntegration)?.app || " "}?`} onChange={(isOpen) => handlePopUpToggle("deleteConfirmation", isOpen)} - deleteKey={(popUp?.deleteConfirmation?.data as TIntegration)?.app || ""} + deleteKey={(popUp?.deleteConfirmation?.data as TIntegration)?.app || (popUp?.deleteConfirmation?.data as TIntegration)?.owner || ""} onDeleteApproved={async () => onIntegrationDelete(popUp?.deleteConfirmation.data as TIntegration, () => handlePopUpClose("deleteConfirmation")