diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 6aa676a44..ffeb748b8 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -1202,7 +1202,19 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) organizations: z .object({ name: z.string(), - slug: z.string() + slug: z.string(), + projects: z + .object({ + name: z.string(), + id: z.string() + }) + .array(), + contexts: z + .object({ + name: z.string(), + id: z.string() + }) + .array() }) .array() }) diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 2dfa65890..3b8078cc1 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -8,7 +8,6 @@ import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { NotFoundError } from "@app/lib/errors"; -import { TCircleCIContext } from "./integration-app-types"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { Integrations, IntegrationUrls } from "./integration-list"; @@ -490,47 +489,6 @@ const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { return apps; }; -/** - * Return list of contexts for CircleCI_Context integration - */ -const getAppsCircleCIContexts = async ({ accessToken, orgSlug }: { accessToken: string; orgSlug: string }) => { - type NextPageToken = string | null | undefined; - - type CircleCIContextResponse = { - items: TCircleCIContext[]; - next_page_token: NextPageToken; - }; - - const contexts: TCircleCIContext[] = []; - - let nextPageToken: NextPageToken; - - while (nextPageToken !== null) { - const res = ( - await request.get(`${IntegrationUrls.CIRCLECI_CONTEXT_API_URL}/v2/context`, { - headers: { - "Circle-Token": accessToken, - "Accept-Encoding": "application/json" - }, - params: new URLSearchParams({ - "owner-slug": orgSlug, - ...(nextPageToken ? { "page-token": nextPageToken } : {}) - }) - }) - ).data; - - contexts.push(...res.items); - nextPageToken = res.next_page_token; - } - - const apps = contexts?.map((context) => ({ - name: context.name, - appId: context.id - })); - - return apps; -}; - /** * Return list of projects for Databricks integration */ @@ -1237,12 +1195,6 @@ export const getApps = async ({ accessToken }); - case Integrations.CIRCLECI_CONTEXT: - return getAppsCircleCIContexts({ - accessToken, - orgSlug: workspaceSlug as string - }); - case Integrations.DATABRICKS: return getAppsDatabricks({ url, diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index cdf52f986..3950b5acb 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; @@ -11,6 +12,7 @@ import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TGenericPermission, TProjectPermission } from "@app/lib/types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; @@ -18,6 +20,7 @@ import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { getApps } from "./integration-app-list"; +import { TCircleCIContext } from "./integration-app-types"; import { TIntegrationAuthDALFactory } from "./integration-auth-dal"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { @@ -1593,8 +1596,8 @@ export const integrationAuthServiceFactory = ({ const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey); - const { data }: { data: TCircleCIOrganization[] } = await request.get( - `${IntegrationUrls.CIRCLECI_CONTEXT_API_URL}/v2/me/collaborations`, + const { data: organizations }: { data: TCircleCIOrganization[] } = await request.get( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, { headers: { "Circle-Token": `${accessToken}`, @@ -1603,7 +1606,106 @@ export const integrationAuthServiceFactory = ({ } ); - return data; + let projects: { + orgName: string; + projectName: string; + projectId?: string; + }[] = []; + + try { + const projectRes = ( + await request.get<{ reponame: string; username: string; vcs_url: string }[]>( + `${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + } + } + ) + ).data; + + projects = projectRes.map((a) => ({ + orgName: a.username, // username maps to unique organization name in CircleCI + projectName: a.reponame, // reponame maps to project name within an organization in CircleCI + projectId: a.vcs_url.split("/").pop() // vcs_url maps to the project id in CircleCI + })); + } catch (error) { + logger.error(error); + } + + const projectsByOrg = projects.reduce>((accum, project) => { + if (!accum[project.orgName]) { + return { + ...accum, + [project.orgName]: [ + { + name: project.projectName, + id: project.projectId as string + } + ] + }; + } + return { + ...accum, + [project.orgName]: [ + ...accum[project.orgName], + { + name: project.projectName, + id: project.projectId as string + } + ] + }; + }, {}); + + const getOrgContexts = async (orgSlug: string) => { + type NextPageToken = string | null | undefined; + + type CircleCIContextResponse = { + items: TCircleCIContext[]; + next_page_token: NextPageToken; + }; + + try { + const contexts: TCircleCIContext[] = []; + let nextPageToken: NextPageToken; + + while (nextPageToken !== null) { + const res = ( + await request.get(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + }, + params: new URLSearchParams({ + "owner-slug": orgSlug, + ...(nextPageToken ? { "page-token": nextPageToken } : {}) + }) + }) + ).data; + + contexts.push(...res.items); + nextPageToken = res.next_page_token; + } + + return contexts?.map((context) => ({ + name: context.name, + id: context.id + })); + } catch (error) { + logger.error(error); + return []; + } + }; + + return Promise.all( + organizations.map(async (org) => ({ + name: org.name, + slug: org.slug, + projects: projectsByOrg[org.name] ?? [], + contexts: (await getOrgContexts(org.slug)) ?? [] + })) + ); }; const deleteIntegrationAuths = async ({ diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index cba923648..b18c07e7b 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -15,7 +15,6 @@ export enum Integrations { FLYIO = "flyio", LARAVELFORGE = "laravel-forge", CIRCLECI = "circleci", - CIRCLECI_CONTEXT = "circleci-context", DATABRICKS = "databricks", TRAVISCI = "travisci", TEAMCITY = "teamcity", @@ -78,7 +77,6 @@ export enum IntegrationUrls { FLYIO_API_URL = "https://api.fly.io/graphql", CIRCLECI_API_URL = "https://circleci.com/api", // eslint-disable-next-line - CIRCLECI_CONTEXT_API_URL = "https://circleci.com/api", DATABRICKS_API_URL = "https:/xxxx.com/api", TRAVISCI_API_URL = "https://api.travis-ci.com", SUPABASE_API_URL = "https://api.supabase.com", @@ -229,15 +227,6 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "" }, - { - name: "CircleCI Contexts", - slug: "circleci-context", - image: "CircleCI.png", - isAvailable: true, - type: "pat", - clientId: "", - docsLink: "" - }, { name: "Databricks", slug: "databricks", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index ed917b936..a16b04be6 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -2245,185 +2245,184 @@ const syncSecretsCircleCI = async ({ secrets: Record; accessToken: string; }) => { - const getProjectSlug = async () => { - const requestConfig = { - headers: { - "Circle-Token": accessToken, - "Accept-Encoding": "application/json" - } - }; + enum CircleCiScope { + Project = "project", + Context = "context" + } - try { - const projectDetails = ( - await request.get<{ slug: string }>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`, - requestConfig - ) - ).data; - - return projectDetails.slug; - } catch (err) { - if (err instanceof AxiosError) { - if (err.response?.data?.message !== "Not Found") { - throw new Error("Failed to get project slug from CircleCI during first attempt."); - } - } - } - - // For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization - try { - const circleCiOrganization = ( - await request.get<{ slug: string; name: string }[]>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, - requestConfig - ) - ).data; - - // Case 1: This is a new integration where the organization name is stored under `integration.owner` - if (integration.owner) { - const org = circleCiOrganization.find((o) => o.name === integration.owner); - if (org) { - return `${org.slug}/${integration.app}`; - } - } - - // Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one - return `${circleCiOrganization[0].slug}/${integration.app}`; - } catch (err) { - throw new Error("Failed to get project slug from CircleCI during second attempt."); - } - }; - - const projectSlug = await getProjectSlug(); - - // sync secrets to CircleCI - await Promise.all( - Object.keys(secrets).map(async (key) => - request.post( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, - { - name: key, - value: secrets[key].value - }, - { - headers: { - "Circle-Token": accessToken, - "Content-Type": "application/json" - } - } - ) - ) - ); - - // get secrets from CircleCI - const getSecretsRes = ( - await request.get<{ items: { name: string }[] }>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, - { - headers: { - "Circle-Token": accessToken, - "Accept-Encoding": "application/json" - } - } - ) - ).data?.items; - - // delete secrets from CircleCI - await Promise.all( - getSecretsRes.map(async (sec) => { - if (!(sec.name in secrets)) { - return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, { - headers: { - "Circle-Token": accessToken, - "Content-Type": "application/json" - } - }); - } - }) - ); -}; - -/** - * Sync/push [secrets] to CircleCI Context - */ -const syncSecretsCircleCIContext = async ({ - integration, - secrets, - accessToken -}: { - integration: TIntegrations; - secrets: Record; - accessToken: string; -}) => { - // sync secrets to CircleCI - await Promise.all( - Object.keys(secrets).map(async (key) => - request.put( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`, - { - value: secrets[key].value - }, - { - headers: { - "Circle-Token": accessToken, - "Content-Type": "application/json" - } - } - ) - ) - ); - - // get secrets from CircleCI - const getSecretsRes = async () => { - type EnvVars = { - variable: string; - created_at: string; - updated_at: string; - context_id: string; - }; - - type ResponseSchema = { - items: EnvVars[]; - next_page_token: string | null; - }; - - let nextPageToken: string | null | undefined; - const envVars: EnvVars[] = []; - - while (nextPageToken !== null) { - const res = await request.get( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, - { - headers: { - "Circle-Token": accessToken, - "Accept-Encoding": "application/json" - } - } - ); - - envVars.push(...res.data.items); - nextPageToken = res.data.next_page_token; - } - - return envVars; - }; - - // delete secrets from CircleCI - await Promise.all( - (await getSecretsRes()).map(async (sec) => { - if (!(sec.variable in secrets)) { - return request.delete( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`, + if (integration.scope === CircleCiScope.Context) { + // sync secrets to CircleCI + await Promise.all( + Object.keys(secrets).map(async (key) => + request.put( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`, + { + value: secrets[key].value + }, { headers: { "Circle-Token": accessToken, "Content-Type": "application/json" } } + ) + ) + ); + + // get secrets from CircleCI + const getSecretsRes = async () => { + type EnvVars = { + variable: string; + created_at: string; + updated_at: string; + context_id: string; + }; + + type ResponseSchema = { + items: EnvVars[]; + next_page_token: string | null; + }; + + let nextPageToken: string | null | undefined; + const envVars: EnvVars[] = []; + + while (nextPageToken !== null) { + const res = await request.get( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + }, + params: nextPageToken + ? new URLSearchParams({ + "page-token": nextPageToken + }) + : undefined + } ); + + envVars.push(...res.data.items); + nextPageToken = res.data.next_page_token; } - }) - ); + + return envVars; + }; + + // delete secrets from CircleCI + await Promise.all( + (await getSecretsRes()).map(async (sec) => { + if (!(sec.variable in secrets)) { + return request.delete( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } + ); + } + }) + ); + } else { + const getProjectSlug = async () => { + const requestConfig = { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + } + }; + + try { + const projectDetails = ( + await request.get<{ slug: string }>( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`, + requestConfig + ) + ).data; + + return projectDetails.slug; + } catch (err) { + if (err instanceof AxiosError) { + if (err.response?.data?.message !== "Not Found") { + throw new Error("Failed to get project slug from CircleCI during first attempt."); + } + } + } + + // For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization + try { + const circleCiOrganization = ( + await request.get<{ slug: string; name: string }[]>( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, + requestConfig + ) + ).data; + + // Case 1: This is a new integration where the organization name is stored under `integration.owner` + if (integration.owner) { + const org = circleCiOrganization.find((o) => o.name === integration.owner); + if (org) { + return `${org.slug}/${integration.app}`; + } + } + + // Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one + return `${circleCiOrganization[0].slug}/${integration.app}`; + } catch (err) { + throw new Error("Failed to get project slug from CircleCI during second attempt."); + } + }; + + const projectSlug = await getProjectSlug(); + + // sync secrets to CircleCI + await Promise.all( + Object.keys(secrets).map(async (key) => + request.post( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, + { + name: key, + value: secrets[key].value + }, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } + ) + ) + ); + + // get secrets from CircleCI + const getSecretsRes = ( + await request.get<{ items: { name: string }[] }>( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + } + } + ) + ).data?.items; + + // delete secrets from CircleCI + await Promise.all( + getSecretsRes.map(async (sec) => { + if (!(sec.name in secrets)) { + return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + }); + } + }) + ); + } }; /** @@ -4516,13 +4515,6 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; - case Integrations.CIRCLECI_CONTEXT: - await syncSecretsCircleCIContext({ - integration, - secrets, - accessToken - }); - break; case Integrations.DATABRICKS: await syncSecretsDatabricks({ integration, diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 5d496db75..51c006424 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -16,7 +16,6 @@ const integrationSlugNameMapping: Mapping = { railway: "Railway", flyio: "Fly.io", circleci: "CircleCI", - "circleci-context": "CircleCI Context", databricks: "Databricks", travisci: "TravisCI", supabase: "Supabase", diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 7a1d469b9..6b20989fe 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -108,6 +108,14 @@ export enum OctopusDeployScope { export type CircleCIOrganization = { name: string; slug: string; + projects: { + name: string; + id: string; + }[]; + contexts: { + name: string; + id: string; + }[]; }; export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = { diff --git a/frontend/src/pages/integrations/circleci-context/authorize.tsx b/frontend/src/pages/integrations/circleci-context/authorize.tsx deleted file mode 100644 index 62ae89bbb..000000000 --- a/frontend/src/pages/integrations/circleci-context/authorize.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState } from "react"; -import Head from "next/head"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2"; -import { useSaveIntegrationAccessToken } from "@app/hooks/api"; - -export default function CircleCIContextCreateIntegrationPage() { - const router = useRouter(); - const { mutateAsync } = useSaveIntegrationAccessToken(); - - const [apiKey, setApiKey] = useState(""); - const [apiKeyErrorText, setApiKeyErrorText] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const handleButtonClick = async () => { - try { - setApiKeyErrorText(""); - if (apiKey.length === 0) { - setApiKeyErrorText("API Key cannot be blank"); - return; - } - - setIsLoading(true); - - const integrationAuth = await mutateAsync({ - workspaceId: localStorage.getItem("projectData.id"), - integration: "circleci-context", - accessToken: apiKey - }); - - setIsLoading(false); - - router.push(`/integrations/circleci-context/create?integrationAuthId=${integrationAuth.id}`); - } catch (err) { - console.error(err); - } - }; - - return ( -
- - Authorize CircleCI Context Integration - - - - -
-
- CircleCI logo - CircleCI Context Integration -
- -
- - Docs - -
- -
-
- - setApiKey(e.target.value)} /> - - -
-
- ); -} - -CircleCIContextCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/circleci-context/create.tsx b/frontend/src/pages/integrations/circleci-context/create.tsx deleted file mode 100644 index da5c064ad..000000000 --- a/frontend/src/pages/integrations/circleci-context/create.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - Card, - CardTitle, - FilterableSelect, - FormControl, - Input, - Spinner -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useCreateIntegration } from "@app/hooks/api"; -import { - useGetIntegrationAuthApps, - useGetIntegrationAuthCircleCIOrganizations -} from "@app/hooks/api/integrationAuth"; - -const formSchema = z.object({ - secretPath: z.string().default("/"), - sourceEnvironment: z.object({ name: z.string(), slug: z.string() }), - targetOrg: z.object({ name: z.string(), slug: z.string() }), - targetContext: z.object({ name: z.string(), appId: z.string() }) -}); - -type TFormData = z.infer; - -export default function CircleCIContextCreateIntegrationPage() { - const router = useRouter(); - const { mutateAsync, isLoading: isCreatingIntegration } = useCreateIntegration(); - const { currentWorkspace, isLoading: isProjectLoading } = useWorkspace(); - - const integrationAuthId = router.query.integrationAuthId as string; - - const { watch, control, reset, handleSubmit } = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - secretPath: "/", - sourceEnvironment: currentWorkspace?.environments[0] - } - }); - - const circleCiOrg = watch("targetOrg"); - - const { data: circleCIOrganizations, isLoading: isCircleCIOrganizationsLoading } = - useGetIntegrationAuthCircleCIOrganizations(integrationAuthId); - - const { data: circleCIContexts } = useGetIntegrationAuthApps( - { - integrationAuthId, - workspaceSlug: circleCiOrg?.slug - }, - - { enabled: Boolean(circleCiOrg?.slug) } - ); - - const onSubmit = async ({ - sourceEnvironment, - secretPath, - targetOrg, - targetContext - }: TFormData) => { - try { - await mutateAsync({ - integrationAuthId, - isActive: true, - sourceEnvironment: sourceEnvironment.slug, - app: targetContext.name, - appId: targetContext.appId, - owner: targetOrg.slug, - secretPath - }); - - createNotification({ - type: "success", - text: "Successfully created integration" - }); - router.push(`/integrations/${currentWorkspace?.id}`); - } catch (err) { - createNotification({ - type: "error", - text: "Failed to create integration" - }); - console.error(err); - } - }; - - useEffect(() => { - if (!circleCIContexts || !circleCIOrganizations || !currentWorkspace) return; - - reset({ - targetOrg: circleCIOrganizations[0], - targetContext: circleCIContexts[0] - }); - }, [circleCIOrganizations, circleCIContexts, currentWorkspace]); - - if (isProjectLoading || isCircleCIOrganizationsLoading) - return ( -
- -
- ); - - return ( -
- - -
-
- CircleCI logo - - CircleCI Context Integration -
- - -
- - Docs - -
- -
-
- ( - - option.slug} - value={value} - getOptionLabel={(option) => option.name} - onChange={onChange} - options={currentWorkspace?.environments} - placeholder="Select a project environment" - isDisabled={!currentWorkspace?.environments.length} - /> - - )} - /> - ( - - - - )} - /> - ( - - option.slug} - value={value} - getOptionLabel={(option) => option.name} - onChange={onChange} - options={circleCIOrganizations} - placeholder={ - circleCIOrganizations?.length - ? "Select an organization..." - : "No organizations found..." - } - isDisabled={!circleCIOrganizations?.length} - /> - - )} - /> - ( - - option.appId!} - getOptionLabel={(option) => option.name} - onChange={onChange} - options={circleCIContexts} - placeholder={ - circleCIContexts?.length ? "Select a context..." : "No contexts found..." - } - isDisabled={!circleCIContexts?.length} - /> - - )} - /> - - -
-
- ); -} - -CircleCIContextCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/circleci/create.tsx b/frontend/src/pages/integrations/circleci/create.tsx index 37c73320b..7e733940e 100644 --- a/frontend/src/pages/integrations/circleci/create.tsx +++ b/frontend/src/pages/integrations/circleci/create.tsx @@ -1,293 +1,296 @@ -import { useEffect, useMemo, useState } from "react"; -import Head from "next/head"; +import { Controller, useForm } from "react-hook-form"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; -import { - faArrowUpRightFromSquare, - faBookOpen, - faBugs, - faCircleInfo -} from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import queryString from "query-string"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { useCreateIntegration } from "@app/hooks/api"; - import { Button, Card, CardTitle, + FilterableSelect, FormControl, - Input, Select, - SelectItem -} from "../../../components/v2"; -import { - useGetIntegrationAuthApps, - useGetIntegrationAuthById -} from "../../../hooks/api/integrationAuth"; -import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + SelectItem, + Spinner +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useWorkspace } from "@app/context"; +import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthCircleCIOrganizations } from "@app/hooks/api/integrationAuth"; + +const formSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal("context"), + secretPath: z.string().default("/"), + sourceEnvironment: z.object({ name: z.string(), slug: z.string() }), + targetOrg: z.object({ name: z.string(), slug: z.string() }), + targetContext: z.object({ name: z.string(), id: z.string() }) + }), + z.object({ + scope: z.literal("project"), + secretPath: z.string().default("/"), + sourceEnvironment: z.object({ name: z.string(), slug: z.string() }), + targetOrg: z.object({ name: z.string(), slug: z.string() }), + targetProject: z.object({ name: z.string(), id: z.string() }) + }) +]); + +type TFormData = z.infer; export default function CircleCICreateIntegrationPage() { const router = useRouter(); - const { mutateAsync } = useCreateIntegration(); + const { mutateAsync, isLoading: isCreatingIntegration } = useCreateIntegration(); + const { currentWorkspace, isLoading: isProjectLoading } = useWorkspace(); - const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + const integrationAuthId = router.query.integrationAuthId as string; - const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); - const { data: integrationAuth, isLoading: isintegrationAuthLoading } = useGetIntegrationAuthById( - (integrationAuthId as string) ?? "" - ); - const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = - useGetIntegrationAuthApps({ - integrationAuthId: (integrationAuthId as string) ?? "" - }); - - const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); - const [targetOrganization, setTargetOrganization] = useState(""); - const [secretPath, setSecretPath] = useState("/"); - - const [targetProjectId, setTargetProjectId] = useState(""); - - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - if (workspace) { - setSelectedSourceEnvironment(workspace.environments[0].slug); + const { control, watch, handleSubmit } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + secretPath: "/", + sourceEnvironment: currentWorkspace?.environments[0] } - }, [workspace]); + }); - const handleButtonClick = async () => { + const selectedScope = watch("scope"); + const selectedOrg = watch("targetOrg"); + + const { data: circleCIOrganizations, isLoading: isCircleCIOrganizationsLoading } = + useGetIntegrationAuthCircleCIOrganizations(integrationAuthId); + + const selectedOrganizationEntry = selectedOrg + ? circleCIOrganizations?.find((org) => org.slug === selectedOrg.slug) + : undefined; + + const onSubmit = async (data: TFormData) => { try { - if (!integrationAuth?.id) return; - - if (!targetProjectId || targetOrganization === "none") { - createNotification({ - type: "error", - text: "Please select a project" + if (data.scope === "context") { + await mutateAsync({ + scope: data.scope, + integrationAuthId, + isActive: true, + sourceEnvironment: data.sourceEnvironment.slug, + app: data.targetContext.name, + appId: data.targetContext.id, + owner: data.targetOrg.name, + secretPath: data.secretPath + }); + } else { + await mutateAsync({ + scope: data.scope, + integrationAuthId, + isActive: true, + app: data.targetProject.name, // project name + owner: data.targetOrg.name, // organization name + appId: data.targetProject.id, // project id (used for syncing) + sourceEnvironment: data.sourceEnvironment.slug, + secretPath: data.secretPath }); - setIsLoading(false); - return; } - setIsLoading(true); - - const selectedApp = integrationAuthApps?.find( - (integrationAuthApp) => integrationAuthApp.appId === targetProjectId - ); - - if (!selectedApp) { - createNotification({ - type: "error", - text: "Invalid project selected" - }); - setIsLoading(false); - return; - } - - await mutateAsync({ - integrationAuthId: integrationAuth?.id, - isActive: true, - app: selectedApp.name, // project name - owner: selectedApp.owner, // organization name - appId: selectedApp.appId, // project id (used for syncing) - sourceEnvironment: selectedSourceEnvironment, - secretPath + createNotification({ + type: "success", + text: "Successfully created integration" }); - - setIsLoading(false); - - router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + router.push(`/integrations/${currentWorkspace?.id}`); } catch (err) { + createNotification({ + type: "error", + text: "Failed to create integration" + }); console.error(err); } }; - const filteredProjects = useMemo(() => { - if (!integrationAuthApps) return []; + if (isProjectLoading || isCircleCIOrganizationsLoading) + return ( +
+ +
+ ); - return integrationAuthApps.filter((integrationAuthApp) => { - return integrationAuthApp.owner === targetOrganization; - }); - }, [integrationAuthApps, targetOrganization]); - - const filteredOrganizations = useMemo(() => { - const organizations = new Set(); - - if (integrationAuthApps) { - integrationAuthApps.forEach((integrationAuthApp) => { - if (!integrationAuthApp.owner) return; - organizations.add(integrationAuthApp.owner); - }); - } - - return Array.from(organizations); - }, [integrationAuthApps]); - - return integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps ? ( -
- - Set Up CircleCI Integration - - - + return ( +
+ -
-
+
+
CircleCI logo + + CircleCI Context Integration
- CircleCI Integration - - -
- - Docs - -
-
+ + +
+ + Docs + +
- - - - - - setSecretPath(evt.target.value)} - placeholder="Provide a path, default is /" - /> - - - - - - - {targetOrganization && ( - - - + option.slug} + value={value} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={currentWorkspace?.environments} + placeholder="Select a project environment" + isDisabled={!currentWorkspace?.environments.length} + /> + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + value={value} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={circleCIOrganizations} + placeholder={ + circleCIOrganizations?.length + ? "Select an organization..." + : "No organizations found..." + } + isDisabled={!circleCIOrganizations?.length} + /> + + )} + /> + ( + + + + )} + /> + {selectedScope === "context" && selectedOrganizationEntry && ( + ( + + option.id!} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={selectedOrganizationEntry?.contexts} + placeholder={ + selectedOrganizationEntry.contexts?.length + ? "Select a context..." + : "No contexts found..." + } + isDisabled={!selectedOrganizationEntry.contexts?.length} + /> + + )} + /> + )} + {selectedScope === "project" && selectedOrganizationEntry && ( + ( + + option.id!} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={selectedOrganizationEntry?.projects} + placeholder={ + selectedOrganizationEntry.projects?.length + ? "Select a project..." + : "No projects found..." + } + isDisabled={!selectedOrganizationEntry.projects?.length} + /> + + )} + /> )} -
-
-
- {" "} - Pro Tip -
- - After creating an integration, your secrets will start syncing immediately. This might - cause an unexpected override of current secrets in CircleCI with secrets from Infisical. - -
-
- ) : ( -
- - Set Up CircleCI Integration - - - {isIntegrationAuthAppsLoading || isintegrationAuthLoading ? ( - infisical loading indicator - ) : ( -
- -

- Something went wrong. Please contact{" "} - - support@infisical.com - {" "} - if the issue persists. -

-
- )} -
+ ); } diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx index 2bd7ee61d..30f906945 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx @@ -46,8 +46,11 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { case "qovery": return integration.scope; case "circleci": - case "circleci-context": - return "Context"; + if (integration.scope === "context") { + return "Context"; + } + + return "Project"; case "terraform-cloud": return "Project"; case "aws-secret-manager": @@ -156,15 +159,6 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { ); } - if (integration.integration === "circleci-context" && integration.owner) { - return ( -
- -
{integration.owner}
-
- ); - } - if (integration.integration === "terraform-cloud" && integration.targetService) { return (
diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 298cb9372..e1a1ff6fb 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -120,9 +120,6 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "circleci": link = `${window.location.origin}/integrations/circleci/authorize`; break; - case "circleci-context": - link = `${window.location.origin}/integrations/circleci-context/authorize`; - break; case "databricks": link = `${window.location.origin}/integrations/databricks/authorize`; break; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx index 95b4c9d84..fee7f9100 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx @@ -15,7 +15,6 @@ export const getIntegrationDestination = (integration: TIntegration) => (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) || integration.app || - (integration.integration === "circleci-context" && `${integration.owner}`) || "-"; export const IntegrationDetails = ({ integration }: Props) => { @@ -53,8 +52,8 @@ export const IntegrationDetails = ({ integration }: Props) => { {
{integration.owner}
)} - {integration.integration === "circleci-context" && integration.owner && ( -
- -
{integration.owner}
-
- )} {integration.integration === "terraform-cloud" && integration.targetService && (