diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b030b79d6..0b82d83fc 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -1,23 +1,16 @@ -import { Request, Response } from 'express'; -import { Types } from 'mongoose'; -import * as Sentry from '@sentry/node'; -import { - Integration, - IntegrationAuth, - Bot -} from '../../models'; -import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables'; -import { IntegrationService } from '../../services'; -import { getApps, revokeAccess } from '../../integrations'; +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import * as Sentry from "@sentry/node"; +import { Integration, IntegrationAuth, Bot } from "../../models"; +import { INTEGRATION_SET, INTEGRATION_OPTIONS } from "../../variables"; +import { IntegrationService } from "../../services"; +import { getApps, revokeAccess } from "../../integrations"; -export const getIntegrationOptions = async ( - req: Request, - res: Response -) => { - return res.status(200).send({ - integrationOptions: INTEGRATION_OPTIONS - }); -} +export const getIntegrationOptions = async (req: Request, res: Response) => { + return res.status(200).send({ + integrationOptions: INTEGRATION_OPTIONS, + }); +}; /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] @@ -25,100 +18,103 @@ export const getIntegrationOptions = async ( * @param res * @returns */ -export const oAuthExchange = async ( - req: Request, - res: Response -) => { - try { - const { workspaceId, code, integration } = req.body; +export const oAuthExchange = async (req: Request, res: Response) => { + try { + const { workspaceId, code, integration } = req.body; - if (!INTEGRATION_SET.has(integration)) - throw new Error('Failed to validate integration'); - - const environments = req.membership.workspace?.environments || []; - if(environments.length === 0){ - throw new Error("Failed to get environments") - } - - await IntegrationService.handleOAuthExchange({ - workspaceId, - integration, - code, - environment: environments[0].slug, - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get OAuth2 code-token exchange' - }); - } + if (!INTEGRATION_SET.has(integration)) + throw new Error("Failed to validate integration"); - return res.status(200).send({ - message: 'Successfully enabled integration authorization' - }); + const environments = req.membership.workspace?.environments || []; + if (environments.length === 0) { + throw new Error("Failed to get environments"); + } + + await IntegrationService.handleOAuthExchange({ + workspaceId, + integration, + code, + environment: environments[0].slug, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get OAuth2 code-token exchange", + }); + } + + return res.status(200).send({ + message: "Successfully enabled integration authorization", + }); }; /** * Save integration access token as part of integration [integration] for workspace with id [workspaceId] - * @param req - * @param res + * @param req + * @param res */ export const saveIntegrationAccessToken = async ( - req: Request, - res: Response + req: Request, + res: Response ) => { - // TODO: refactor - let integrationAuth; - try { - const { - workspaceId, - accessToken, - integration - }: { - workspaceId: string; - accessToken: string; - integration: string; - } = req.body; + // TODO: refactor + let integrationAuth; + try { + const { + workspaceId, + accessToken, + integration, + }: { + workspaceId: string; + accessToken: string; + integration: string; + } = req.body; - integrationAuth = await IntegrationAuth.findOneAndUpdate({ - workspace: new Types.ObjectId(workspaceId), - integration - }, { - workspace: new Types.ObjectId(workspaceId), - integration - }, { - new: true, - upsert: true - }); + integrationAuth = await IntegrationAuth.findOneAndUpdate( + { + workspace: new Types.ObjectId(workspaceId), + integration, + }, + { + workspace: new Types.ObjectId(workspaceId), + integration, + }, + { + new: true, + upsert: true, + } + ); - const bot = await Bot.findOne({ - workspace: new Types.ObjectId(workspaceId), - isActive: true - }); - - if (!bot) throw new Error('Bot must be enabled to save integration access token'); - - // encrypt and save integration access token - integrationAuth = await IntegrationService.setIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id.toString(), - accessToken, - accessExpiresAt: undefined - }); - - if (!integrationAuth) throw new Error('Failed to save integration access token'); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to save access token for integration' - }); - } - - return res.status(200).send({ - integrationAuth - }); -} + const bot = await Bot.findOne({ + workspace: new Types.ObjectId(workspaceId), + isActive: true, + }); + + if (!bot) + throw new Error("Bot must be enabled to save integration access token"); + + // encrypt and save integration access token + integrationAuth = await IntegrationService.setIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString(), + accessToken, + accessExpiresAt: undefined, + }); + + if (!integrationAuth) + throw new Error("Failed to save integration access token"); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to save access token for integration", + }); + } + + return res.status(200).send({ + integrationAuth, + }); +}; /** * Return list of applications allowed for integration with integration authorization id [integrationAuthId] @@ -127,23 +123,24 @@ export const saveIntegrationAccessToken = async ( * @returns */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { - let apps; - try { - apps = await getApps({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get integration authorization applications' - }); - } + let apps; + try { + apps = await getApps({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken, + }); + } catch (err) { + console.log(err); // testing + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to get integration authorization applications", + }); + } - return res.status(200).send({ - apps - }); + return res.status(200).send({ + apps, + }); }; /** @@ -153,21 +150,21 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { * @returns */ export const deleteIntegrationAuth = async (req: Request, res: Response) => { - let integrationAuth; - try { - integrationAuth = await revokeAccess({ - integrationAuth: req.integrationAuth, - accessToken: req.accessToken - }); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete integration authorization' - }); - } - - return res.status(200).send({ - integrationAuth - }); -} \ No newline at end of file + let integrationAuth; + try { + integrationAuth = await revokeAccess({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken, + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to delete integration authorization", + }); + } + + return res.status(200).send({ + integrationAuth, + }); +}; diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 4e66ce0aa..aa81acfd6 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -1,44 +1,40 @@ -import { Request, Response } from 'express'; -import * as Sentry from '@sentry/node'; -import { - Integration, - Workspace, - Bot, - BotKey -} from '../../models'; -import { EventService } from '../../services'; -import { eventPushSecrets } from '../../events'; +import { Request, Response } from "express"; +import * as Sentry from "@sentry/node"; +import { Integration, Workspace, Bot, BotKey } from "../../models"; +import { EventService } from "../../services"; +import { eventPushSecrets } from "../../events"; /** * Create/initialize an (empty) integration for integration authorization - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const createIntegration = async (req: Request, res: Response) => { - let integration; - try { - // initialize new integration after saving integration access token - integration = await new Integration({ - workspace: req.integrationAuth.workspace._id, - isActive: false, - app: null, - environment: req.integrationAuth.workspace?.environments[0].slug, - integration: req.integrationAuth.integration, - integrationAuth: req.integrationAuth._id - }).save(); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to create integration' - }); - } + let integration; + try { + // initialize new integration after saving integration access token + integration = await new Integration({ + workspace: req.integrationAuth.workspace._id, + isActive: false, + app: null, + environment: req.integrationAuth.workspace?.environments[0].slug, + integration: req.integrationAuth.integration, + integrationAuth: req.integrationAuth._id, + }).save(); + } catch (err) { + console.log(err); + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to create integration", + }); + } - return res.status(200).send({ - integration - }); -} + return res.status(200).send({ + integration, + }); +}; /** * Change environment or name of integration with id [integrationId] @@ -47,57 +43,57 @@ export const createIntegration = async (req: Request, res: Response) => { * @returns */ export const updateIntegration = async (req: Request, res: Response) => { - let integration; - - // TODO: add integration-specific validation to ensure that each - // integration has the correct fields populated in [Integration] - - try { - const { - environment, - isActive, - app, - appId, - targetEnvironment, - owner, // github-specific integration param - } = req.body; - - integration = await Integration.findOneAndUpdate( - { - _id: req.integration._id - }, - { - environment, - isActive, - app, - appId, - targetEnvironment, - owner - }, - { - new: true - } - ); - - if (integration) { - // trigger event - push secrets - EventService.handleEvent({ - event: eventPushSecrets({ - workspaceId: integration.workspace.toString() - }) - }); - } - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to update integration' - }); - } + let integration; - return res.status(200).send({ - integration - }); + // TODO: add integration-specific validation to ensure that each + // integration has the correct fields populated in [Integration] + + try { + const { + environment, + isActive, + app, + appId, + targetEnvironment, + owner, // github-specific integration param + } = req.body; + + integration = await Integration.findOneAndUpdate( + { + _id: req.integration._id, + }, + { + environment, + isActive, + app, + appId, + targetEnvironment, + owner, + }, + { + new: true, + } + ); + + if (integration) { + // trigger event - push secrets + EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: integration.workspace.toString(), + }), + }); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to update integration", + }); + } + + return res.status(200).send({ + integration, + }); }; /** @@ -108,24 +104,24 @@ export const updateIntegration = async (req: Request, res: Response) => { * @returns */ export const deleteIntegration = async (req: Request, res: Response) => { - let integration; - try { - const { integrationId } = req.params; + let integration; + try { + const { integrationId } = req.params; - integration = await Integration.findOneAndDelete({ - _id: integrationId - }); - - if (!integration) throw new Error('Failed to find integration'); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete integration' - }); - } - - return res.status(200).send({ - integration - }); + integration = await Integration.findOneAndDelete({ + _id: integrationId, + }); + + if (!integration) throw new Error("Failed to find integration"); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to delete integration", + }); + } + + return res.status(200).send({ + integration, + }); }; diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 26d606f87..c971597f6 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -321,9 +321,10 @@ const getAppsCircleci = async ({ accessToken }: { accessToken: string }) => { await axios.get(`${INTEGRATION_CIRCLECI_API_URL}/v2/me/collaborations`, { headers: { "Circle-Token": accessToken, + "Accept-Encoding": "application/json", }, }) - ).data; + ).data[0]; const { slug } = circleciOrganizationDetail; @@ -333,15 +334,17 @@ const getAppsCircleci = async ({ accessToken }: { accessToken: string }) => { { headers: { "Circle-Token": accessToken, + "Accept-Encoding": "application/json", }, } ) - ).data.items; + ).data?.items; apps = res.map((a: any) => ({ name: a?.project_slug?.split("/")[2], })); } catch (err) { + console.log(err); Sentry.setUser(null); Sentry.captureException(err); throw new Error("Failed to get Render services"); diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index ddc3329ec..670b05893 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -87,12 +87,12 @@ const syncSecrets = async ({ accessToken, }); break; - // case INTEGRATION_CIRCLECI: - // await syncSecretsCircleci({ - // integration, - // secrets, - // accessToken, - // }); + case INTEGRATION_CIRCLECI: + await syncSecretsCircleci({ + integration, + secrets, + accessToken, + }); } } catch (err) { Sentry.setUser(null); @@ -831,14 +831,67 @@ const syncSecretsFlyio = async ({ } }; -// const syncSecretsCircleci = async ({ -// integration, -// secrets, -// accessToken, -// }: { -// integration: IIntegration; -// secrets: any; -// accessToken: string; -// }) => {}; +const syncSecretsCircleci = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + const circleciOrganizationDetail = ( + await axios.get(`${INTEGRATION_CIRCLECI_API_URL}/v2/me/collaborations`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json", + }, + }) + ).data[0]; + + const { slug } = circleciOrganizationDetail; + + // get secrets from CircleCI + const getSecretsRes = ( + await axios.get( + `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json", + }, + } + ) + ).data?.items; + + console.log(getSecretsRes); + console.log(secrets); + + // inject secrets to CircleCI + // note: no relivent api end point was found in CircleCI to do entire secrets at a same time so + // it is done one by one + Object.keys(secrets).forEach( + async (key) => + await axios.post( + `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, + { + name: key, + value: secrets[key], + }, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json", + }, + } + ) + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to CircleCI"); + } +}; export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index d95d490d3..6417f2beb 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -6,6 +6,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_RENDER, INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, } from "../variables"; export interface IIntegration { @@ -74,6 +75,7 @@ const integrationSchema = new Schema( INTEGRATION_GITHUB, INTEGRATION_RENDER, INTEGRATION_FLYIO, + INTEGRATION_CIRCLECI, ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 694ce6a67..c219d3292 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -4,6 +4,7 @@ import { INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_CIRCLECI, } from "../variables"; export interface IIntegrationAuth { @@ -42,6 +43,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_CIRCLECI, ], required: true, }, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 0ae70303b..883e8a730 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -138,8 +138,8 @@ const INTEGRATION_OPTIONS = [ name: "Circle CI", slug: "circleci", image: "Circle CI.png", - isAvailable: false, - type: "", + isAvailable: true, + type: "pat", clientId: "", docsLink: "", }, diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 806460997..91af2ff25 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -1,24 +1,16 @@ // membership roles -const OWNER = 'owner'; -const ADMIN = 'admin'; -const MEMBER = 'member'; +const OWNER = "owner"; +const ADMIN = "admin"; +const MEMBER = "member"; // membership statuses -const INVITED = 'invited'; +const INVITED = "invited"; // membership permissions ability -const ABILITY_READ = 'read'; -const ABILITY_WRITE = 'write'; +const ABILITY_READ = "read"; +const ABILITY_WRITE = "write"; // -- organization -const ACCEPTED = 'accepted'; +const ACCEPTED = "accepted"; -export { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - ABILITY_READ, - ABILITY_WRITE -} \ No newline at end of file +export { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED, ABILITY_READ, ABILITY_WRITE }; diff --git a/frontend/src/components/integrations/CloudIntegration.tsx b/frontend/src/components/integrations/CloudIntegration.tsx index cc06bdd8e..9536957ce 100644 --- a/frontend/src/components/integrations/CloudIntegration.tsx +++ b/frontend/src/components/integrations/CloudIntegration.tsx @@ -44,9 +44,9 @@ const CloudIntegration = ({ tabIndex={0} className={`relative ${ cloudIntegrationOption.isAvailable - ? 'hover:bg-white/10 duration-200 cursor-pointer' + ? 'cursor-pointer duration-200 hover:bg-white/10' : 'opacity-50' - } flex flex-row bg-white/5 h-32 rounded-md p-4 items-center`} + } flex h-32 flex-row items-center rounded-md bg-white/5 p-4`} onClick={() => { if (!cloudIntegrationOption.isAvailable) return; setSelectedIntegrationOption(cloudIntegrationOption); @@ -61,22 +61,22 @@ const CloudIntegration = ({ alt="integration logo" /> {cloudIntegrationOption.name.split(' ').length > 2 ? ( -
+
{cloudIntegrationOption.name.split(' ')[0]}
{cloudIntegrationOption.name.split(' ')[1]} {cloudIntegrationOption.name.split(' ')[2]}
) : ( -
+
{cloudIntegrationOption.name}
)} {cloudIntegrationOption.isAvailable && integrationAuths - .map((authorization) => authorization.integration) + .map((authorization) => authorization?.integration) .includes(cloudIntegrationOption.slug) && ( -
+
null} role="button" @@ -86,8 +86,7 @@ const CloudIntegration = ({ const deletedIntegrationAuth = await deleteIntegrationAuth({ integrationAuthId: integrationAuths .filter( - (authorization) => - authorization.integration === cloudIntegrationOption.slug + (authorization) => authorization.integration === cloudIntegrationOption.slug ) .map((authorization) => authorization._id)[0] }); @@ -96,20 +95,20 @@ const CloudIntegration = ({ integrationAuth: deletedIntegrationAuth }); }} - className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200" + className="flex w-max cursor-pointer flex-row items-center rounded-b-md bg-red py-0.5 px-2 text-xs opacity-0 duration-200 group-hover:opacity-100" > - + Revoke
-
- +
+ Authorized
)} {!cloudIntegrationOption.isAvailable && ( -
-
+
+
Coming Soon
diff --git a/frontend/src/components/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index abec74f9d..e177f462c 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -43,8 +43,8 @@ type Props = { handleDeleteIntegration: (args: { integration: Integration }) => void; }; -const IntegrationTile = ({ - integration, +const IntegrationTile = ({ + integration, integrations, bot, setBot, @@ -54,7 +54,7 @@ const IntegrationTile = ({ }: Props) => { // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( - environments.find(({ slug }) => slug === integration.environment) || { + environments.find(({ slug }) => slug === integration?.environment) || { name: '', slug: '' } @@ -66,27 +66,27 @@ const IntegrationTile = ({ useEffect(() => { const loadIntegration = async () => { - const tempApps: [IntegrationApp] = await getIntegrationApps({ - integrationAuthId: integration.integrationAuth + integrationAuthId: integration?.integrationAuth }); - + setApps(tempApps); - setIntegrationApp(integration.app ? integration.app : tempApps[0].name); + setIntegrationApp(integration?.app ? integration.app : tempApps[0].name); switch (integration.integration) { case 'vercel': setIntegrationTargetEnvironment( integration?.targetEnvironment - ? integration.targetEnvironment.charAt(0).toUpperCase() + integration.targetEnvironment.substring(1) - : 'Development' + ? integration.targetEnvironment.charAt(0).toUpperCase() + + integration.targetEnvironment.substring(1) + : 'Development' ); break; case 'netlify': setIntegrationTargetEnvironment( - integration?.targetEnvironment - ? contextNetlifyMapping[integration.targetEnvironment] - : 'Local development' + integration?.targetEnvironment + ? contextNetlifyMapping[integration.targetEnvironment] + : 'Local development' ); break; default: @@ -96,7 +96,7 @@ const IntegrationTile = ({ loadIntegration(); }, []); - + const handleStartIntegration = async () => { const reformatTargetEnvironment = (targetEnvironment: string) => { switch (integration.integration) { @@ -107,13 +107,13 @@ const IntegrationTile = ({ default: return null; } - } + }; try { const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined const appId = siteApp?.appId ?? null; const owner = siteApp?.owner ?? null; - + // return updated integration const updatedIntegration = await updateIntegration({ integrationId: integration._id, @@ -124,15 +124,15 @@ const IntegrationTile = ({ targetEnvironment: reformatTargetEnvironment(integrationTargetEnvironment), owner }); - + setIntegrations( - integrations.map((i) => i._id === updatedIntegration._id ? updatedIntegration : i) + integrations.map((i) => (i._id === updatedIntegration._id ? updatedIntegration : i)) ); } catch (err) { console.error(err); } - } - + }; + // eslint-disable-next-line @typescript-eslint/no-shadow const renderIntegrationSpecificParams = (integration: Integration) => { try { @@ -140,7 +140,7 @@ const IntegrationTile = ({ case 'vercel': return (
-
ENVIRONMENT
+
ENVIRONMENT
-
CONTEXT
+
CONTEXT
; return ( -
+
-

ENVIRONMENT

+

ENVIRONMENT

name) : null} isSelected={integrationEnvironment.name} @@ -196,16 +196,16 @@ const IntegrationTile = ({ />
- +
-

INTEGRATION

-
+

INTEGRATION

+
{integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)}
-
APP
+
APP
app.name) : null} isSelected={integrationApp} @@ -218,9 +218,9 @@ const IntegrationTile = ({
{integration.isActive ? ( -
- -
In Sync
+
+ +
In Sync
) : (