diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index e67136275..00196b3d0 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -10,6 +10,8 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CLOUDFLARE_WORKERS, + INTEGRATION_CLOUDFLARE_WORKERS_API_URL, INTEGRATION_CLOUD_66, INTEGRATION_CLOUD_66_API_URL, INTEGRATION_CODEFRESH, @@ -186,6 +188,12 @@ const getApps = async ({ accountId: accessId }); break; + case INTEGRATION_CLOUDFLARE_WORKERS: + apps = await getAppsCloudflareWorkers({ + accessToken, + accountId: accessId + }); + break; case INTEGRATION_NORTHFLANK: apps = await getAppsNorthflank({ accessToken @@ -968,6 +976,40 @@ const getAppsCloudflarePages = async ({ return apps; }; +/** + * Return list of projects for the Cloudflare Workers integration + * @param {Object} obj + * @param {String} obj.accessToken - api key for the Cloudflare API + * @returns {Object[]} apps - Cloudflare Workers projects + * @returns {String} apps.id - Id of Cloudflare Workers project + * @returns {String} apps.name - Id of Cloudflare Workers project (Cloudflare workers API does not return the name) + */ +const getAppsCloudflareWorkers = async ({ + accessToken, + accountId +}: { + accessToken: string; + accountId?: string; +}) => { + const { data } = await standardRequest.get( + `${INTEGRATION_CLOUDFLARE_WORKERS_API_URL}/client/v4/accounts/${accountId}/workers/services`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + + const apps = data.result.map((a: any) => { + return { + name: a.id, + appId: a.id + }; + }); + return apps; +}; + /** * Return list of repositories for the BitBucket integration based on provided BitBucket workspace * @param {Object} obj diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 1670651b2..615e3114c 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -18,6 +18,8 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CLOUDFLARE_PAGES_API_URL, + INTEGRATION_CLOUDFLARE_WORKERS, + INTEGRATION_CLOUDFLARE_WORKERS_API_URL, INTEGRATION_CLOUD_66, INTEGRATION_CLOUD_66_API_URL, INTEGRATION_CODEFRESH, @@ -262,6 +264,14 @@ const syncSecrets = async ({ accessToken }); break; + case INTEGRATION_CLOUDFLARE_WORKERS: + await syncSecretsCloudflareWorkers({ + integration, + secrets, + accessId, + accessToken + }); + break; case INTEGRATION_CODEFRESH: await syncSecretsCodefresh({ integration, @@ -2746,6 +2756,98 @@ const syncSecretsCloudflarePages = async ({ ); }; +/** + * Sync/push [secrets] to Cloudflare Workers project with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - API token for Cloudflare workers + */ +const syncSecretsCloudflareWorkers = async ({ + integration, + secrets, + accessId, + accessToken +}: { + integration: IIntegration; + secrets: Record; + accessId: string | null; + accessToken: string; +}) => { + // get secrets from cloudflare workers + const getSecretsRes = ( + await standardRequest.get( + `${INTEGRATION_CLOUDFLARE_WORKERS_API_URL}/client/v4/accounts/${accessId}/workers/scripts/${integration.app}/secrets`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ) + ).data.result; + + const secretsObj: any = getSecretKeyValuePair(secrets); + + for (const [key, val] of Object.entries(secretsObj)) { + secretsObj[key] = { type: "secret_text", value: val }; + } + + // get deleted secrets list + const deletedSecretKeys: string[] = []; + if (getSecretsRes) { + getSecretsRes.forEach((secretRes: any) => { + if (!(Object.keys(secrets).includes(secretRes.name))) { + deletedSecretKeys.push(secretRes.name); + } + }) + } + + deletedSecretKeys.forEach(async (secretKey) => { + await standardRequest.delete( + `${INTEGRATION_CLOUDFLARE_WORKERS_API_URL}/client/v4/accounts/${accessId}/workers/scripts/${integration.app}/secrets/${secretKey}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + }); + + interface ConvertedSecret { + name: string; + text: string; + type: string; + } + + interface SecretsObj { + [key: string]: { + type: string; + value: string; + }; + } + + const data: ConvertedSecret[] = Object.entries(secretsObj as SecretsObj).map(([name, secret]) => ({ + name, + text: secret.value, + type: "secret_text" + })); + + data.forEach(async (secret) => { + await standardRequest.put( + `${INTEGRATION_CLOUDFLARE_WORKERS_API_URL}/client/v4/accounts/${accessId}/workers/scripts/${integration.app}/secrets`, + secret, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + }) +}; + /** * Sync/push [secrets] to BitBucket repo with name [integration.app] * @param {Object} obj diff --git a/backend/src/models/integration/integration.ts b/backend/src/models/integration/integration.ts index 8c997a28d..eaaadec92 100644 --- a/backend/src/models/integration/integration.ts +++ b/backend/src/models/integration/integration.ts @@ -6,6 +6,7 @@ import { INTEGRATION_CHECKLY, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUDFLARE_WORKERS, INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, @@ -71,6 +72,7 @@ export interface IIntegration { | "teamcity" | "hashicorp-vault" | "cloudflare-pages" + | "cloudflare-workers" | "bitbucket" | "codefresh" | "digital-ocean-app-platform" @@ -179,6 +181,7 @@ const integrationSchema = new Schema( INTEGRATION_TEAMCITY, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUDFLARE_WORKERS, INTEGRATION_CODEFRESH, INTEGRATION_WINDMILL, INTEGRATION_BITBUCKET, diff --git a/backend/src/models/integrationAuth/integrationAuth.ts b/backend/src/models/integrationAuth/integrationAuth.ts index e65c126af..da1e57268 100644 --- a/backend/src/models/integrationAuth/integrationAuth.ts +++ b/backend/src/models/integrationAuth/integrationAuth.ts @@ -8,6 +8,7 @@ import { INTEGRATION_BITBUCKET, INTEGRATION_CIRCLECI, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUDFLARE_WORKERS, INTEGRATION_CLOUD_66, INTEGRATION_CODEFRESH, INTEGRATION_DIGITAL_OCEAN_APP_PLATFORM, @@ -55,6 +56,7 @@ export interface IIntegrationAuth extends Document { | "checkly" | "qovery" | "cloudflare-pages" + | "cloudflare-workers" | "codefresh" | "digital-ocean-app-platform" | "bitbucket" @@ -113,6 +115,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUDFLARE_WORKERS, INTEGRATION_CODEFRESH, INTEGRATION_WINDMILL, INTEGRATION_BITBUCKET, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index a70429082..28848dbb6 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -32,6 +32,7 @@ export const INTEGRATION_QOVERY = "qovery"; export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; export const INTEGRATION_HASHICORP_VAULT = "hashicorp-vault"; export const INTEGRATION_CLOUDFLARE_PAGES = "cloudflare-pages"; +export const INTEGRATION_CLOUDFLARE_WORKERS = "cloudflare-workers"; export const INTEGRATION_BITBUCKET = "bitbucket"; export const INTEGRATION_CODEFRESH = "codefresh"; export const INTEGRATION_WINDMILL = "windmill"; @@ -59,6 +60,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, + INTEGRATION_CLOUDFLARE_WORKERS, INTEGRATION_CODEFRESH, INTEGRATION_WINDMILL, INTEGRATION_BITBUCKET, @@ -101,6 +103,7 @@ export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; export const INTEGRATION_QOVERY_API_URL = "https://api.qovery.com"; export const INTEGRATION_TERRAFORM_CLOUD_API_URL = "https://app.terraform.io"; export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; +export const INTEGRATION_CLOUDFLARE_WORKERS_API_URL = "https://api.cloudflare.com"; export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; export const INTEGRATION_WINDMILL_API_URL = "https://app.windmill.dev/api"; @@ -316,6 +319,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "" }, + { + name: "Cloudflare Workers", + slug: "cloudflare-workers", + image: "Cloudflare.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, { name: "BitBucket", slug: "bitbucket", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index b40c55d55..451890ef9 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -24,6 +24,7 @@ const integrationSlugNameMapping: Mapping = { teamcity: "TeamCity", "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", + "cloudflare-workers": "Cloudflare Workers", codefresh: "Codefresh", "digital-ocean-app-platform": "Digital Ocean App Platform", bitbucket: "BitBucket", diff --git a/frontend/src/pages/integrations/cloudflare-workers/authorize.tsx b/frontend/src/pages/integrations/cloudflare-workers/authorize.tsx new file mode 100644 index 000000000..3b46751fc --- /dev/null +++ b/frontend/src/pages/integrations/cloudflare-workers/authorize.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { + useSaveIntegrationAccessToken +} from "@app/hooks/api"; + +import { Button,Card, CardTitle, FormControl, Input } from "../../../components/v2"; + +export default function CloudflareWorkersIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useSaveIntegrationAccessToken(); + + const [accessKey, setAccessKey] = useState(""); + const [accessKeyErrorText, setAccessKeyErrorText] = useState(""); + const [accountId, setAccountId] = useState(""); + const [accountIdErrorText, setAccountIdErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setAccessKeyErrorText(""); + setAccountIdErrorText(""); + if (accessKey.length === 0 || accountId.length === 0) { + if (accessKey.length === 0) setAccessKeyErrorText("API token cannot be blank!"); + if (accountId.length === 0) setAccountIdErrorText("Account ID cannot be blank!"); + return; + } + + setIsLoading(true); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "cloudflare-workers", + accessId: accountId, + accessToken: accessKey + }); + + setAccessKey(""); + setAccountId(""); + setIsLoading(false); + + router.push(`/integrations/cloudflare-workers/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + } + + return ( +
+ + Cloudflare Workers Integration + + setAccessKey(e.target.value)} + /> + + + setAccountId(e.target.value)} + /> + + + +
+ ); +} + +CloudflareWorkersIntegrationPage.requireAuth = true; \ No newline at end of file diff --git a/frontend/src/pages/integrations/cloudflare-workers/create.tsx b/frontend/src/pages/integrations/cloudflare-workers/create.tsx new file mode 100644 index 000000000..333bdd17f --- /dev/null +++ b/frontend/src/pages/integrations/cloudflare-workers/create.tsx @@ -0,0 +1,133 @@ +import { useEffect,useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + useCreateIntegration +, useGetWorkspaceById } from "@app/hooks/api"; + +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2"; +import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; + +export default function CloudflareWorkersIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [targetApp, setTargetApp] = useState(""); + const [targetAppId, setTargetAppId] = useState(""); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + setTargetAppId(String(integrationAuthApps[0].appId)); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await mutateAsync({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: targetAppId, + sourceEnvironment: selectedSourceEnvironment, + secretPath: "/", + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch(err) { + console.error(err); + } + } + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + Cloudflare Workers Integration + + + + + + + + +
+ ) : ( +
+ ); +} + +CloudflareWorkersIntegrationPage.requireAuth = true; \ No newline at end of file diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 5e92ec4ee..bb517950a 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -101,6 +101,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "cloudflare-pages": link = `${window.location.origin}/integrations/cloudflare-pages/authorize`; break; + case "cloudflare-workers": + link = `${window.location.origin}/integrations/cloudflare-workers/authorize`; + break; case "bitbucket": link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`; break;