diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 6929726e9..58568145d 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -16,6 +16,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_HEROKU_API_URL, INTEGRATION_GITLAB_API_URL, INTEGRATION_VERCEL_API_URL, @@ -25,6 +26,7 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL } from "../variables"; interface App { @@ -116,6 +118,11 @@ const getApps = async ({ accessToken, }) break; + case INTEGRATION_SUPABASE: + apps = await getAppsSupabase({ + accessToken + }); + break; } } catch (err) { Sentry.setUser(null); @@ -608,4 +615,40 @@ const getAppsGitlab = async ({ return apps; } + +/** + * Return list of projects for Supabase integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Supabase API + * @returns {Object[]} apps - names of Supabase apps + * @returns {String} apps.name - name of Supabase app + */ +const getAppsSupabase = async ({ accessToken }: { accessToken: string }) => { + let apps: any; + try { + const { data } = await request.get( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' + } + } + ); + + apps = data.map((a: any) => { + return { + name: a.name, + appId: a.id + }; + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Supabase projects'); + } + + return apps; +}; + export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 9a998d6f5..285f31ca7 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -25,6 +25,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_HEROKU_API_URL, INTEGRATION_GITLAB_API_URL, INTEGRATION_VERCEL_API_URL, @@ -34,6 +35,7 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL } from "../variables"; import request from '../config/request'; import axios from "axios"; @@ -157,6 +159,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_SUPABASE: + await syncSecretsSupabase({ + integration, + secrets, + accessToken + }); + break; } } catch (err) { Sentry.setUser(null); @@ -1618,4 +1627,79 @@ const syncSecretsGitLab = async ({ } } +/** + * Sync/push [secrets] to Supabase with name [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth 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 - access token for Supabase integration + */ +const syncSecretsSupabase = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + const { data: getSecretsRes } = await request.get( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' + } + } + ); + + // convert the secrets to [{}] format + const modifiedFormatForSecretInjection = Object.keys(secrets).map( + (key) => { + return { + name: key, + value: secrets[key] + }; + } + ); + + await request.post( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, + modifiedFormatForSecretInjection, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' + } + } + ); + + const secretsToDelete: any = []; + getSecretsRes?.forEach((secretObj: any) => { + if (!(secretObj.name in secrets)) { + secretsToDelete.push(secretObj.name); + } + }); + + await request.delete( + `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'application/json' + }, + data: secretsToDelete + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Supabase'); + } +}; + + export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 22a8713e0..2a77c95e9 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -13,6 +13,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE } from "../variables"; export interface IIntegration { @@ -42,7 +43,8 @@ export interface IIntegration { | 'railway' | 'flyio' | 'circleci' - | 'travisci'; + | 'travisci' + | 'supabase'; integrationAuth: Types.ObjectId; } @@ -122,6 +124,7 @@ const integrationSchema = new Schema( INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 934005ec8..ead969fc7 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -13,12 +13,13 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, } from "../variables"; export interface IIntegrationAuth extends Document { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'aws-parameter-store' | 'aws-secret-manager'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'supabase' | 'aws-parameter-store' | 'aws-secret-manager'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -56,6 +57,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE ], required: true, }, diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 3029b265f..979de21ab 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -19,6 +19,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -36,6 +37,7 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL, getIntegrationOptions } from "./integration"; import { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED } from "./organization"; @@ -102,6 +104,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -119,6 +122,7 @@ export { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_LOGIN, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 3c25c79da..bbfd7f107 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -21,6 +21,7 @@ const INTEGRATION_RAILWAY = "railway"; const INTEGRATION_FLYIO = "flyio"; const INTEGRATION_CIRCLECI = "circleci"; const INTEGRATION_TRAVISCI = "travisci"; +const INTEGRATION_SUPABASE = 'supabase'; const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -32,6 +33,7 @@ const INTEGRATION_SET = new Set([ INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE ]); // integration types @@ -57,6 +59,7 @@ const INTEGRATION_RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"; const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; +const INTEGRATION_SUPABASE_API_URL = 'https://api.supabase.com'; const getIntegrationOptions = () => { const INTEGRATION_OPTIONS = [ @@ -178,6 +181,15 @@ const getIntegrationOptions = () => { clientId: '', docsLink: '' }, + { + name: 'Supabase', + slug: 'supabase', + image: 'Supabase.png', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' + }, { name: 'Google Cloud Platform', slug: 'gcp', @@ -207,6 +219,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, + INTEGRATION_SUPABASE, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -224,5 +237,6 @@ export { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, + INTEGRATION_SUPABASE_API_URL, getIntegrationOptions }; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index f5e5e5016..40c88f65b 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -15,7 +15,8 @@ const integrationSlugNameMapping: Mapping = { 'railway': 'Railway', 'flyio': 'Fly.io', 'circleci': 'CircleCI', - 'travisci': 'TravisCI' + 'travisci': 'TravisCI', + 'supabase': 'Supabase' } const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Supabase.png b/frontend/public/images/integrations/Supabase.png new file mode 100644 index 000000000..843be59ca Binary files /dev/null and b/frontend/public/images/integrations/Supabase.png differ diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index be9e3718f..46870805d 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -207,6 +207,9 @@ export default function Integrations() { case 'travisci': link = `${window.location.origin}/integrations/travisci/authorize`; break; + case 'supabase': + link = `${window.location.origin}/integrations/supabase/authorize`; + break; case 'railway': link = `${window.location.origin}/integrations/railway/authorize`; break; @@ -262,6 +265,9 @@ export default function Integrations() { case 'travisci': link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`; break; + case 'supabase': + link = `${window.location.origin}/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`; + break; case 'railway': link = `${window.location.origin}/integrations/railway/create?integrationAuthId=${integrationAuth._id}`; break; diff --git a/frontend/src/pages/integrations/supabase/authorize.tsx b/frontend/src/pages/integrations/supabase/authorize.tsx new file mode 100644 index 000000000..95785be6f --- /dev/null +++ b/frontend/src/pages/integrations/supabase/authorize.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { Button, Card, CardTitle, FormControl, Input } from '../../../components/v2'; +import saveIntegrationAccessToken from '../../api/integrations/saveIntegrationAccessToken'; + +export default function SupabaseCreateIntegrationPage() { + const router = useRouter(); + 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 saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'supabase', + accessToken: apiKey, + accessId: null + }); + + setIsLoading(false); + + router.push(`/integrations/supabase/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Supabase Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +SupabaseCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/supabase/create.tsx b/frontend/src/pages/integrations/supabase/create.tsx new file mode 100644 index 000000000..16e35e36e --- /dev/null +++ b/frontend/src/pages/integrations/supabase/create.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from '../../../components/v2'; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from '../../api/integrations/createIntegration'; + +export default function SupabaseCreateIntegrationPage() { + const router = useRouter(); + + 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 [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: + integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp) + ?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem('projectData.id')}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + Supabase Integration + + + + + + + + +
+ ) : ( +
+ ); +} + +SupabaseCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file