diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index e00e06b77..ffeb748b8 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -1185,4 +1185,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) return { spaces }; } }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/circleci/organizations", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + organizations: z + .object({ + name: 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() + }) + } + }, + handler: async (req) => { + const organizations = await server.services.integrationAuth.getCircleCIOrganizations({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + return { organizations }; + } + }); }; diff --git a/backend/src/services/integration-auth/integration-app-types.ts b/backend/src/services/integration-auth/integration-app-types.ts new file mode 100644 index 000000000..1ddd2e4d2 --- /dev/null +++ b/backend/src/services/integration-auth/integration-app-types.ts @@ -0,0 +1,5 @@ +export type TCircleCIContext = { + id: string; + name: string; + created_at: string; +}; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index b4bbbd7cb..e2957be98 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -17,6 +17,8 @@ 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 { groupBy } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; import { TGenericPermission, TProjectPermission } from "@app/lib/types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; @@ -24,6 +26,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 { @@ -31,6 +34,7 @@ import { TBitbucketEnvironment, TBitbucketWorkspace, TChecklyGroups, + TCircleCIOrganization, TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthsDTO, TDuplicateGithubIntegrationAuthDTO, @@ -42,6 +46,7 @@ import { TIntegrationAuthBitbucketEnvironmentsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthCircleCIOrganizationDTO, TIntegrationAuthGithubEnvsDTO, TIntegrationAuthGithubOrgsDTO, TIntegrationAuthHerokuPipelinesDTO, @@ -1578,6 +1583,120 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getCircleCIOrganizations = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TIntegrationAuthCircleCIOrganizationDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey); + + const { data: organizations }: { data: TCircleCIOrganization[] } = await request.get( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, + { + headers: { + "Circle-Token": `${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + 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 = groupBy( + projects.map((p) => ({ + orgName: p.orgName, + name: p.projectName, + id: p.projectId as string + })), + (p) => p.orgName + ); + + const getOrgContexts = async (orgSlug: string) => { + type NextPageToken = string | null | undefined; + + try { + const contexts: TCircleCIContext[] = []; + let nextPageToken: NextPageToken; + + while (nextPageToken !== null) { + // eslint-disable-next-line no-await-in-loop + const { data } = await request.get<{ + items: TCircleCIContext[]; + next_page_token: NextPageToken; + }>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + }, + params: new URLSearchParams({ + "owner-slug": orgSlug, + ...(nextPageToken ? { "page-token": nextPageToken } : {}) + }) + }); + + contexts.push(...data.items); + nextPageToken = data.next_page_token; + } + + return contexts?.map((context) => ({ + name: context.name, + id: context.id + })); + } catch (error) { + logger.error(error); + } + }; + + 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 ({ projectId, integration, @@ -1790,6 +1909,7 @@ export const integrationAuthServiceFactory = ({ getTeamcityBuildConfigs, getBitbucketWorkspaces, getBitbucketEnvironments, + getCircleCIOrganizations, getIntegrationAccessToken, duplicateIntegrationAuth, getOctopusDeploySpaces, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 3ffa6959a..68d7bf5b9 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -128,6 +128,10 @@ export type TGetIntegrationAuthTeamCityBuildConfigDTO = { appId: string; } & Omit; +export type TIntegrationAuthCircleCIOrganizationDTO = { + id: string; +} & Omit; + export type TVercelBranches = { ref: string; lastCommit: string; @@ -189,6 +193,14 @@ export type TTeamCityBuildConfig = { webUrl: string; }; +export type TCircleCIOrganization = { + id: string; + vcsType: string; + name: string; + avatarUrl: string; + slug: string; +}; + export type TIntegrationsWithEnvironment = TIntegrations & { environment?: | { @@ -215,6 +227,11 @@ export enum OctopusDeployScope { // add tenant, variable set, etc. } +export enum CircleCiScope { + Project = "project", + Context = "context" +} + export type TOctopusDeployVariableSet = { Id: string; OwnerId: string; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 45cbdaea9..d6da2194d 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -76,7 +76,6 @@ export enum IntegrationUrls { RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2", FLYIO_API_URL = "https://api.fly.io/graphql", CIRCLECI_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", LARAVELFORGE_API_URL = "https://forge.laravel.com", @@ -218,9 +217,9 @@ export const getIntegrationOptions = async () => { docsLink: "" }, { - name: "Circle CI", + name: "CircleCI", slug: "circleci", - image: "Circle CI.png", + image: "CircleCI.png", isAvailable: true, type: "pat", clientId: "", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index c147150f0..cd8b8baea 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -39,7 +39,12 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/ import { TIntegrationDALFactory } from "../integration/integration-dal"; import { IntegrationMetadataSchema } from "../integration/integration-schema"; import { IntegrationAuthMetadataSchema } from "./integration-auth-schema"; -import { OctopusDeployScope, TIntegrationsWithEnvironment, TOctopusDeployVariableSet } from "./integration-auth-types"; +import { + CircleCiScope, + OctopusDeployScope, + TIntegrationsWithEnvironment, + TOctopusDeployVariableSet +} from "./integration-auth-types"; import { IntegrationInitialSyncBehavior, IntegrationMappingBehavior, @@ -2245,102 +2250,174 @@ const syncSecretsCircleCI = async ({ secrets: Record; accessToken: string; }) => { - 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 + 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" + } + } ) - ).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."); - } - } - } + // get secrets from CircleCI + const getSecretsRes = async () => { + type EnvVars = { + variable: string; + created_at: string; + updated_at: string; + context_id: string; + }; - // 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; + let nextPageToken: string | null | undefined; + const envVars: EnvVars[] = []; - // 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 - }, - { + while (nextPageToken !== null) { + const res = await request.get<{ + items: EnvVars[]; + next_page_token: string | null; + }>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, { headers: { "Circle-Token": accessToken, - "Content-Type": "application/json" - } - } - ) - ) - ); + "Accept-Encoding": "application/json" + }, + params: nextPageToken + ? new URLSearchParams({ + "page-token": nextPageToken + }) + : undefined + }); - // get secrets from CircleCI - const getSecretsRes = ( - await request.get<{ items: { name: string }[] }>( - `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`, - { + 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" } - } - ) - ).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}`, { + 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, - "Content-Type": "application/json" + "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" + } + }); + } + }) + ); + } }; /** diff --git a/docs/images/integrations/circleci/integrations-circleci-auth.png b/docs/images/integrations/circleci/integrations-circleci-auth.png index 055ebbf4a..73a5fd686 100644 Binary files a/docs/images/integrations/circleci/integrations-circleci-auth.png and b/docs/images/integrations/circleci/integrations-circleci-auth.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci-create-context.png b/docs/images/integrations/circleci/integrations-circleci-create-context.png new file mode 100644 index 000000000..9d911953e Binary files /dev/null and b/docs/images/integrations/circleci/integrations-circleci-create-context.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci-create-project.png b/docs/images/integrations/circleci/integrations-circleci-create-project.png new file mode 100644 index 000000000..73ab1e75a Binary files /dev/null and b/docs/images/integrations/circleci/integrations-circleci-create-project.png differ diff --git a/docs/images/integrations/circleci/integrations-circleci.png b/docs/images/integrations/circleci/integrations-circleci.png index 5ee9a9df0..cde74678d 100644 Binary files a/docs/images/integrations/circleci/integrations-circleci.png and b/docs/images/integrations/circleci/integrations-circleci.png differ diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx index 0753f40f7..5bf04822d 100644 --- a/docs/integrations/cicd/circleci.mdx +++ b/docs/integrations/cicd/circleci.mdx @@ -11,21 +11,30 @@ Prerequisites: Obtain an API token in User Settings > Personal API Tokens - ![integrations circleci token](../../images/integrations/circleci/integrations-circleci-token.png) + ![integrations circleci token](/images/integrations/circleci/integrations-circleci-token.png) Navigate to your project's integrations tab in Infisical. - ![integrations](../../images/integrations.png) + ![integrations](/images/integrations.png) Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. - ![integrations circleci authorization](../../images/integrations/circleci/integrations-circleci-auth.png) + ![integrations circleci authorization](/images/integrations/circleci/integrations-circleci-auth.png) - Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI. + Select which Infisical environment secrets you want to sync to which CircleCI project or context. + + + ![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-project.png) + + + ![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-context.png) + + + + Finally, press create integration to start syncing secrets to CircleCI. + ![integrations circleci](/images/integrations/circleci/integrations-circleci.png) - ![create integration circleci](../../images/integrations/circleci/integrations-circleci-create.png) - ![integrations circleci](../../images/integrations/circleci/integrations-circleci.png) - \ No newline at end of file + diff --git a/frontend/public/images/integrations/Circle CI.png b/frontend/public/images/integrations/CircleCI.png similarity index 100% rename from frontend/public/images/integrations/Circle CI.png rename to frontend/public/images/integrations/CircleCI.png diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 0ae3511de..e7ee5928a 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -7,6 +7,7 @@ export { useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, + useGetIntegrationAuthCircleCIOrganizations, useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthNorthflankSecretGroups, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index e5f928158..84a50ae1f 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -8,6 +8,7 @@ import { BitBucketEnvironment, BitBucketWorkspace, ChecklyGroup, + CircleCIOrganization, Environment, HerokuPipelineCoupling, IntegrationAuth, @@ -128,7 +129,9 @@ const integrationAuthKeys = { integrationAuthId, ...params }: TGetIntegrationAuthOctopusDeployScopeValuesDTO) => - [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const + [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const, + getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) => + [{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -510,6 +513,15 @@ const fetchIntegrationAuthOctopusDeployScopeValues = async ({ return data; }; +const fetchIntegrationAuthCircleCIOrganizations = async (integrationAuthId: string) => { + const { + data: { organizations } + } = await apiRequest.get<{ + organizations: CircleCIOrganization[]; + }>(`/api/v1/integration-auth/${integrationAuthId}/circleci/organizations`); + return organizations; +}; + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -884,6 +896,13 @@ export const useGetIntegrationAuthTeamCityBuildConfigs = ({ }); }; +export const useGetIntegrationAuthCircleCIOrganizations = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthCircleCIOrganizations(integrationAuthId), + queryFn: () => fetchIntegrationAuthCircleCIOrganizations(integrationAuthId) + }); +}; + export const useAuthorizeIntegration = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 58a643dff..e2dee6067 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -105,6 +105,19 @@ export enum OctopusDeployScope { // tenant, variable set } +export type CircleCIOrganization = { + name: string; + slug: string; + projects: { + name: string; + id: string; + }[]; + contexts: { + name: string; + id: string; + }[]; +}; + export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = { integrationAuthId: string; spaceId: string; @@ -125,3 +138,8 @@ export type TOctopusDeployVariableSetScopeValues = { Name: string; }[]; }; + +export enum CircleCiScope { + Context = "context", + Project = "project" +} diff --git a/frontend/src/pages/integrations/circleci/authorize.tsx b/frontend/src/pages/integrations/circleci/authorize.tsx index fc57c19ce..fe2c40ebd 100644 --- a/frontend/src/pages/integrations/circleci/authorize.tsx +++ b/frontend/src/pages/integrations/circleci/authorize.tsx @@ -56,7 +56,7 @@ export default function CircleCICreateIntegrationPage() {
CircleCI logo; 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, setValue } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + secretPath: "/", + sourceEnvironment: currentWorkspace?.environments[0], + scope: CircleCiScope.Project } - }, [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 === CircleCiScope.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={(e) => { + setValue("targetProject", { + name: "", + id: "" + }); + setValue("targetContext", { + name: "", + id: "" + }); + + onChange(e); + }} + options={circleCIOrganizations} + placeholder={ + circleCIOrganizations?.length + ? "Select an organization..." + : "No organizations found..." + } + isDisabled={!circleCIOrganizations?.length} + /> + + )} + /> + ( + + + + )} + /> + {selectedScope === CircleCiScope.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 === CircleCiScope.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 778cdd00a..465760996 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx @@ -1,6 +1,7 @@ import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { FormLabel } from "@app/components/v2"; +import { CircleCiScope } from "@app/hooks/api/integrationAuth/types"; import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; type Props = { @@ -46,6 +47,11 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { case "qovery": return integration.scope; case "circleci": + if (integration.scope === CircleCiScope.Context) { + return "Context"; + } + + return "Project"; case "terraform-cloud": return "Project"; case "aws-secret-manager": @@ -77,7 +83,6 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { return `${integration.owner}`; } return `${integration.owner}/${integration.app}`; - case "aws-parameter-store": case "rundeck": return `${integration.path}`; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx index f785ca745..4e31cdedb 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx @@ -1,4 +1,5 @@ import { FormLabel } from "@app/components/v2"; +import { CircleCiScope } from "@app/hooks/api/integrationAuth/types"; import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types"; type Props = { @@ -52,7 +53,8 @@ export const IntegrationDetails = ({ integration }: Props) => {