From 3f3516b7baacd2d4848cdcb07d44f69cae3bf77d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 9 Mar 2023 11:52:34 +0700 Subject: [PATCH 1/3] Checkpoint GitLab integration group support --- .../v1/integrationAuthController.ts | 67 ++++++--- backend/src/integrations/apps.ts | 5 + backend/src/integrations/exchange.ts | 48 +++++- backend/src/integrations/index.ts | 2 + backend/src/integrations/teams.ts | 74 ++++++++++ backend/src/models/integrationAuth.ts | 6 +- backend/src/routes/v1/integrationAuth.ts | 19 ++- backend/src/variables/integration.ts | 3 +- .../src/hooks/api/integrationAuth/index.tsx | 3 +- .../src/hooks/api/integrationAuth/queries.tsx | 50 ++++++- .../src/hooks/api/integrationAuth/types.ts | 5 + frontend/src/pages/integrations/[id].tsx | 8 +- .../pages/integrations/circleci/create.tsx | 37 +++-- .../src/pages/integrations/flyio/create.tsx | 28 +++- .../src/pages/integrations/github/create.tsx | 35 +++-- .../src/pages/integrations/gitlab/create.tsx | 138 +++++++++++++++--- .../src/pages/integrations/heroku/create.tsx | 35 +++-- .../src/pages/integrations/netlify/create.tsx | 41 ++++-- .../src/pages/integrations/render/create.tsx | 36 +++-- .../pages/integrations/travisci/create.tsx | 39 +++-- .../src/pages/integrations/vercel/create.tsx | 42 ++++-- 21 files changed, 572 insertions(+), 149 deletions(-) create mode 100644 backend/src/integrations/teams.ts diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 11cbd6f643..4d6f6b37d5 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -8,7 +8,11 @@ import { } from '../../models'; import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables'; import { IntegrationService } from '../../services'; -import { getApps, revokeAccess } from '../../integrations'; +import { + getApps, + getTeams, + revokeAccess +} from '../../integrations'; /*** * Return integration authorization with id [integrationAuthId] @@ -154,25 +158,54 @@ 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 { + const teamId = req.query.teamId as string; - return res.status(200).send({ - apps, - }); + apps = await getApps({ + integrationAuth: req.integrationAuth, + accessToken: req.accessToken, + ...teamId && { teamId } + }); + } catch (err) { + 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 list of teams allowed for integration with integration authorization id [integrationAuthId] + * @param req + * @param res + * @returns + */ +export const getIntegrationAuthTeams = async (req: Request, res: Response) => { + let teams; + try { + teams = await getTeams({ + 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 teams" + }); + } + + return res.status(200).send({ + teams + }); +} + /** * Delete integration authorization with id [integrationAuthId] * @param req diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 2bb666119d..521c51a2f0 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -24,22 +24,27 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, } from "../variables"; +import { requireIntegrationAuthorizationAuth } from "../middleware"; /** * Return list of names of apps for integration named [integration] * @param {Object} obj * @param {String} obj.integration - name of integration * @param {String} obj.accessToken - access token for integration + * @param {String} obj.teamId - (optional) id of team for getting integration apps (used for integrations like GitLab) * @returns {Object[]} apps - names of integration apps * @returns {String} apps.name - name of integration app */ const getApps = async ({ integrationAuth, accessToken, + teamId }: { integrationAuth: IIntegrationAuth; accessToken: string; + teamId?: string; }) => { + interface App { name: string; appId?: string; diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 3cabd4f9ee..45ad74370e 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -13,6 +13,7 @@ import { INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_GITHUB_TOKEN_URL, INTEGRATION_GITLAB_TOKEN_URL, + INTEGRATION_GITLAB_API_URL } from '../variables'; import { SITE_URL, @@ -73,7 +74,7 @@ interface ExchangeCodeGithubResponse { interface ExchangeCodeGitlabResponse { access_token: string; token_type: string; - expires_in: string; + expires_in: number; refresh_token: string; scope: string; created_at: number; @@ -370,6 +371,7 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { */ const exchangeCodeGitlab = async ({ code }: { code: string }) => { let res: ExchangeCodeGitlabResponse; + const accessExpiresAt = new Date(); try { res = ( @@ -389,6 +391,46 @@ const exchangeCodeGitlab = async ({ code }: { code: string }) => { } ) ).data; + + // 1. try getting groups + // https://gitlab.com/api/v4/groups + + // const res2 = (await request.get( + // `${INTEGRATION_GITLAB_API_URL}/v4/groups`, + // { + // headers: { + // Authorization: `Bearer ${res.access_token}`, + // "Accept-Encoding": "application/json" + // } + // } + // )).data; + + // 2. try getting projects of that group + // const res3 = (await request.get( + // `${INTEGRATION_GITLAB_API_URL}/v4/groups/${res2[0].id}`, + // { + // headers: { + // Authorization: `Bearer ${res.access_token}`, + // "Accept-Encoding": "application/json" + // } + // } + // )).data; + + // const res = ( + // await request.get( + // `${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`, + // { + // headers: { + // "Authorization": `Bearer ${accessToken}`, + // "Accept-Encoding": "application/json", + // }, + // } + // ) + // ).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); }catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -397,8 +439,8 @@ const exchangeCodeGitlab = async ({ code }: { code: string }) => { return { accessToken: res.access_token, - refreshToken: null, - accessExpiresAt: null + refreshToken: res.refresh_token, + accessExpiresAt }; } diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts index 86c22de0cf..8439ff3ba2 100644 --- a/backend/src/integrations/index.ts +++ b/backend/src/integrations/index.ts @@ -1,6 +1,7 @@ import { exchangeCode } from './exchange'; import { exchangeRefresh } from './refresh'; import { getApps } from './apps'; +import { getTeams } from './teams'; import { syncSecrets } from './sync'; import { revokeAccess } from './revoke'; @@ -8,6 +9,7 @@ export { exchangeCode, exchangeRefresh, getApps, + getTeams, syncSecrets, revokeAccess } \ No newline at end of file diff --git a/backend/src/integrations/teams.ts b/backend/src/integrations/teams.ts new file mode 100644 index 0000000000..41040aee56 --- /dev/null +++ b/backend/src/integrations/teams.ts @@ -0,0 +1,74 @@ +import * as Sentry from "@sentry/node"; +import { + IIntegrationAuth +} from '../models'; +import { + INTEGRATION_GITLAB, + INTEGRATION_GITLAB_API_URL +} from '../variables'; +import request from '../config/request'; + +const getTeams = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + interface Team { + name: string; + teamId: string; + } + + let teams: Team[] = []; + try { + switch (integrationAuth.integration) { + case INTEGRATION_GITLAB: + teams = await getTeamsGitLab({ + accessToken + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration teams'); + } + + + return teams; +} + +const getTeamsGitLab = async ({ + accessToken +}: { + accessToken: string; +}) => { + let teams = []; + try { + const res = (await request.get( + `${INTEGRATION_GITLAB_API_URL}/v4/groups`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + )).data; + + teams = res.map((t: any) => ({ + name: t.name, + teamId: t.id + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to get GitLab integration teams"); + } + + return teams; +} + +export { + getTeams +} \ No newline at end of file diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 8f4aed6633..43bef63ad8 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -23,9 +23,9 @@ export interface IIntegrationAuth { refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; - accessIdCiphertext?: string; // new - accessIdIV?: string; // new - accessIdTag?: string; // new + accessIdCiphertext?: string; + accessIdIV?: string; + accessIdTag?: string; accessCiphertext?: string; accessIV?: string; accessTag?: string; diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 8d960e03c5..409422e93c 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -1,6 +1,6 @@ import express from 'express'; const router = express.Router(); -import { body, param } from 'express-validator'; +import { body, param, query } from 'express-validator'; import { requireAuth, requireWorkspaceAuth, @@ -64,6 +64,9 @@ router.post( integrationAuthController.saveIntegrationAccessToken ); +// this can optionally accept a teamId? +// IF teamId is passed in then it probably means that we want to get +// the apps for that team router.get( '/:integrationAuthId/apps', requireAuth({ @@ -73,10 +76,24 @@ router.get( acceptedRoles: [ADMIN, MEMBER] }), param('integrationAuthId'), + query('entity'), validateRequest, integrationAuthController.getIntegrationAuthApps ); +router.get( + '/:integrationAuthId/teams', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('integrationAuthId'), + validateRequest, + integrationAuthController.getIntegrationAuthTeams +); + router.delete( '/:integrationAuthId', requireAuth({ diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index d0280fbff1..5ed93a597f 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -59,6 +59,7 @@ 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"; +// TODO: deprecate types? const INTEGRATION_OPTIONS = [ { name: 'Heroku', @@ -156,7 +157,7 @@ const INTEGRATION_OPTIONS = [ slug: 'gitlab', image: 'GitLab.png', isAvailable: true, - type: 'oauth', + type: 'custom', clientId: CLIENT_ID_GITLAB, docsLink: '' }, diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 43920b65fa..c02938985d 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -1,3 +1,4 @@ export { useGetIntegrationAuthApps, - useGetIntegrationAuthById} from './queries'; \ No newline at end of file + useGetIntegrationAuthById, + useGetIntegrationAuthTeams} from './queries'; \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 508afc7417..e60436f993 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -4,11 +4,13 @@ import { apiRequest } from "@app/config/request"; import { App, - IntegrationAuth} from './types'; + IntegrationAuth, + Team} from './types'; const integrationAuthKeys = { getIntegrationAuthById: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuth'] as const, - getIntegrationAuthApps: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuthApps'] as const, + getIntegrationAuthApps: (integrationAuthId: string, teamId?: string) => [{ integrationAuthId, teamId }, 'integrationAuthApps'] as const, + getIntegrationAuthTeams: (integrationAuthId: string) => [{ integrationAuthId }, 'integrationAuthTeams'] as const } const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -16,11 +18,27 @@ const fetchIntegrationAuthById = async (integrationAuthId: string) => { return data.integrationAuth; } -const fetchIntegrationAuthApps = async (integrationAuthId: string) => { - const { data } = await apiRequest.get<{ apps: App[] }>(`/api/v1/integration-auth/${integrationAuthId}/apps`); +const fetchIntegrationAuthApps = async ({ + integrationAuthId, + teamId +}: { + integrationAuthId: string; + teamId?: string; +}) => { + console.log('fetchIntegrationAuthApps: ', integrationAuthId); + const searchParams = new URLSearchParams(teamId ? { teamId } : undefined); + const { data } = await apiRequest.get<{ apps: App[] }>( + `/api/v1/integration-auth/${integrationAuthId}/apps`, + { params: searchParams } + ); return data.apps; } +const fetchIntegrationAuthTeams = async (integrationAuthId: string) => { + const { data } = await apiRequest.get<{ teams: Team[] }>(`/api/v1/integration-auth/${integrationAuthId}/teams`); + return data.teams; +} + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -29,10 +47,28 @@ export const useGetIntegrationAuthById = (integrationAuthId: string) => { }); } -export const useGetIntegrationAuthApps = (integrationAuthId: string) => { +// TODO: fix to teamId +export const useGetIntegrationAuthApps = ({ + integrationAuthId, + teamId +}: { + integrationAuthId: string; + teamId?: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthApps(integrationAuthId, teamId), + queryFn: () => fetchIntegrationAuthApps({ + integrationAuthId, + teamId + }), + enabled: true + }); +} + +export const useGetIntegrationAuthTeams = (integrationAuthId: string) => { return useQuery({ - queryKey: integrationAuthKeys.getIntegrationAuthApps(integrationAuthId), - queryFn: () => fetchIntegrationAuthApps(integrationAuthId), + queryKey: integrationAuthKeys.getIntegrationAuthTeams(integrationAuthId), + queryFn: () => fetchIntegrationAuthTeams(integrationAuthId), enabled: true }); } \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index bf193eafe2..4ba19c1929 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -10,4 +10,9 @@ export type App = { name: string; appId?: string; owner?: string; +} + +export type Team = { + name: string; + teamId: string; } \ No newline at end of file diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index 0305febad2..c3c625a797 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -196,16 +196,16 @@ export default function Integrations() { link = `https://gitlab.com/oauth/authorize?client_id=${integrationOption.clientId}&redirect_uri=${window.location.origin}/integrations/gitlab/oauth2/callback&response_type=code&state=${state}`; break; case 'render': - link = `${window.location.origin}/integrations/render/authorize` + link = `${window.location.origin}/integrations/render/authorize`; break; case 'flyio': - link = `${window.location.origin}/integrations/flyio/authorize` + link = `${window.location.origin}/integrations/flyio/authorize`; break; case 'circleci': - link = `${window.location.origin}/integrations/circleci/authorize` + link = `${window.location.origin}/integrations/circleci/authorize`; break; case 'travisci': - link = `${window.location.origin}/integrations/travisci/authorize` + link = `${window.location.origin}/integrations/travisci/authorize`; break; default: break; diff --git a/frontend/src/pages/integrations/circleci/create.tsx b/frontend/src/pages/integrations/circleci/create.tsx index 8e1416dd77..b753e7dd70 100644 --- a/frontend/src/pages/integrations/circleci/create.tsx +++ b/frontend/src/pages/integrations/circleci/create.tsx @@ -22,7 +22,9 @@ export default function CircleCICreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); @@ -36,9 +38,12 @@ export default function CircleCICreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty if (integrationAuthApps) { - setTargetApp(integrationAuthApps[0]?.name); + if (integrationAuthApps.length > 0 ) { + setTargetApp(integrationAuthApps[0]?.name); + } else { + setTargetApp('none'); + } } }, [integrationAuthApps]); @@ -84,7 +89,7 @@ export default function CircleCICreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} @@ -98,19 +103,27 @@ export default function CircleCICreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No projects found - ))} + )} diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx index 2c971065fc..05b37affd4 100644 --- a/frontend/src/pages/integrations/flyio/create.tsx +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -22,7 +22,9 @@ export default function FlyioCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); @@ -31,14 +33,18 @@ export default function FlyioCreateIntegrationPage() { useEffect(() => { if (workspace) { - setSelectedSourceEnvironment(workspace.environments[0].slug); + setSelectedSourceEnvironment(workspace.environments[0].slug); } }, [workspace]); useEffect(() => { // TODO: handle case where apps can be empty if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); + } } }, [integrationAuthApps]); @@ -84,7 +90,7 @@ export default function FlyioCreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} @@ -98,12 +104,19 @@ export default function FlyioCreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No apps found - ))} + )} diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 8de610fa93..5fbd8f780f 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -22,7 +22,9 @@ export default function GitHubCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [owner, setOwner] = useState(null); @@ -37,10 +39,13 @@ export default function GitHubCreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { setTargetApp(integrationAuthApps[0].name); setOwner(integrationAuthApps[0]?.owner ?? null); + } else { + setTargetApp('none'); + } } }, [integrationAuthApps]); @@ -99,21 +104,29 @@ export default function GitHubCreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No repositories found - ))} + )} diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index f1b747ba71..9bd7d4ac19 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -11,10 +11,20 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById, + useGetIntegrationAuthTeams} from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; +const gitLabEntities = [ + { name: 'Individual', value: 'individual' }, + { name: 'Group', value: 'group' } +] + +// TODO: flesh out the data flow... + export default function GitLabCreateIntegrationPage() { const router = useRouter(); @@ -22,10 +32,18 @@ export default function GitLabCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + + const [targetEntity, setTargetEntity] = useState(gitLabEntities[0].value); + const [targetTeam, setTargetTeam] = useState('aa'); // ? + const [targetTeamId] = useState(undefined); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '', + ...(targetTeamId ? { teamId: targetTeamId } : {}) + }); + const { data: integrationAuthTeams } = useGetIntegrationAuthTeams(integrationAuthId as string ?? ''); + console.log('integrationAuthTeams: ', integrationAuthTeams); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); - const [owner, setOwner] = useState(null); const [targetApp, setTargetApp] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -38,12 +56,36 @@ export default function GitLabCreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty - if (integrationAuthApps) { - setTargetApp(integrationAuthApps[0].name); - setOwner(integrationAuthApps[0]?.owner ?? null); + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + console.log('AA'); + setTargetApp(integrationAuthApps[0].name); + } else { + console.log('BB'); + setTargetApp('none'); } + } }, [integrationAuthApps]); + + useEffect(() => { + // if (targetEntity === 'group' && integrationAuthTeams) { + // if (integrationAuthTeams.length > 0) { + // setTargetTeam(integrationAuthTeams[0].name); + // } else { + // setTargetTeam('none'); + // } + // } + + // if (targetEntity === 'group') { + // if (integrationAuthTeams && integrationAuthTeams.length > 0) { + // setTargetTeamId(integrationAuthTeams[0].teamId); + // } else { + // setTargetTeamId(''); + // } + // } else { + + // } + }, [integrationAuthTeams, integrationAuthApps, targetEntity]); const handleButtonClick = async () => { try { @@ -57,7 +99,7 @@ export default function GitLabCreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, - owner, + owner: null, path: null, region: null }); @@ -71,7 +113,15 @@ export default function GitLabCreateIntegrationPage() { } } - return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( + console.log('A', (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && integrationAuthTeams && targetApp && targetTeam)); + console.log('B', integrationAuth); + console.log('C', workspace); + console.log('D', selectedSourceEnvironment); + console.log('E', integrationAuthApps); + console.log('F', integrationAuthTeams); + console.log('G', targetApp); + console.log('H', targetTeam); + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && integrationAuthTeams && targetApp && targetTeam) ? (
GitLab Integration @@ -85,33 +135,83 @@ export default function GitLabCreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} + + + {targetEntity === 'group' && ( + + + + )} + diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index 1ba48e1486..cae8dfe44d 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -22,7 +22,9 @@ export default function HerokuCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); @@ -36,9 +38,12 @@ export default function HerokuCreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); + } } }, [integrationAuthApps]); @@ -83,7 +88,7 @@ export default function HerokuCreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} @@ -97,19 +102,27 @@ export default function HerokuCreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No apps found - ))} + )} diff --git a/frontend/src/pages/integrations/netlify/create.tsx b/frontend/src/pages/integrations/netlify/create.tsx index 03201f6365..fb33e404b1 100644 --- a/frontend/src/pages/integrations/netlify/create.tsx +++ b/frontend/src/pages/integrations/netlify/create.tsx @@ -29,7 +29,9 @@ export default function NetlifyCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); @@ -45,10 +47,13 @@ export default function NetlifyCreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty - if (integrationAuthApps) { - setTargetApp(integrationAuthApps[0].name); + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); } + } }, [integrationAuthApps]); const handleButtonClick = async () => { @@ -91,7 +96,7 @@ export default function NetlifyCreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} @@ -104,12 +109,19 @@ export default function NetlifyCreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No sites found - ))} + )} {netlifyEnvironments.map((netlifyEnvironment) => ( - + {netlifyEnvironment.name} ))} diff --git a/frontend/src/pages/integrations/render/create.tsx b/frontend/src/pages/integrations/render/create.tsx index c8ab9d37ea..cc61eec3e2 100644 --- a/frontend/src/pages/integrations/render/create.tsx +++ b/frontend/src/pages/integrations/render/create.tsx @@ -22,7 +22,9 @@ export default function RenderCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); @@ -38,7 +40,11 @@ export default function RenderCreateIntegrationPage() { useEffect(() => { // TODO: handle case where apps can be empty if (integrationAuthApps) { - setTargetApp(integrationAuthApps[0].name); + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); + } } }, [integrationAuthApps]); @@ -84,7 +90,7 @@ export default function RenderCreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} @@ -98,19 +104,27 @@ export default function RenderCreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No services found - ))} + )} diff --git a/frontend/src/pages/integrations/travisci/create.tsx b/frontend/src/pages/integrations/travisci/create.tsx index 426f3b9357..c4a8a8f958 100644 --- a/frontend/src/pages/integrations/travisci/create.tsx +++ b/frontend/src/pages/integrations/travisci/create.tsx @@ -22,7 +22,9 @@ export default function TravisCICreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); @@ -36,10 +38,13 @@ export default function TravisCICreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty - if (integrationAuthApps) { - setTargetApp(integrationAuthApps[0]?.name); + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp('none'); } + } }, [integrationAuthApps]); const handleButtonClick = async () => { @@ -84,7 +89,7 @@ export default function TravisCICreateIntegrationPage() { className='w-full border border-mineshaft-500' > {workspace?.environments.map((sourceEnvironment) => ( - + {sourceEnvironment.name} ))} @@ -98,19 +103,27 @@ export default function TravisCICreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No projects found - ))} + )} diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx index 1805837880..6431b45976 100644 --- a/frontend/src/pages/integrations/vercel/create.tsx +++ b/frontend/src/pages/integrations/vercel/create.tsx @@ -28,11 +28,13 @@ export default function VercelCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const { data: integrationAuthApps } = useGetIntegrationAuthApps(integrationAuthId as string ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [targetApp, setTargetApp] = useState(''); - const [targetEnvironment, setTargetEnvironemnt] = useState(''); + const [targetEnvironment, setTargetEnvironment] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -43,10 +45,14 @@ export default function VercelCreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { setTargetApp(integrationAuthApps[0].name); - setTargetEnvironemnt(vercelEnvironments[0].slug); + setTargetEnvironment(vercelEnvironments[0].slug); + } else { + setTargetApp('none'); + setTargetEnvironment(vercelEnvironments[0].slug); + } } }, [integrationAuthApps]); @@ -103,12 +109,19 @@ export default function VercelCreateIntegrationPage() { value={targetApp} onValueChange={(val) => setTargetApp(val)} className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} > - {integrationAuthApps.map((integrationAuthApp) => ( - - {integrationAuthApp.name} + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No projects found - ))} + )} From 0269b58a3c08ff624bbf9d9d9b3dfb3bd9ed8a61 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 10 Mar 2023 21:25:04 +0700 Subject: [PATCH 2/3] Finish support for GitLab groups integration --- .../v1/integrationAuthController.ts | 3 +- .../controllers/v1/integrationController.ts | 22 ++- backend/src/helpers/integration.ts | 2 +- backend/src/integrations/apps.ts | 137 +++++++++++---- backend/src/integrations/exchange.ts | 43 +---- backend/src/integrations/refresh.ts | 163 ++++++++++++++++-- backend/src/integrations/sync.ts | 7 +- backend/src/integrations/teams.ts | 28 ++- backend/src/models/integration.ts | 2 - backend/src/routes/v1/integrationAuth.ts | 5 +- backend/src/services/IntegrationService.ts | 3 - .../src/hooks/api/integrationAuth/queries.tsx | 1 - .../src/pages/integrations/gitlab/create.tsx | 82 ++++----- 13 files changed, 318 insertions(+), 180 deletions(-) diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 4d6f6b37d5..d26bab1ccd 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -2,7 +2,6 @@ import { Request, Response } from 'express'; import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { - Integration, IntegrationAuth, Bot } from '../../models'; @@ -161,7 +160,7 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => { let apps; try { const teamId = req.query.teamId as string; - + apps = await getApps({ integrationAuth: req.integrationAuth, accessToken: req.accessToken, diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index a17e4d7a67..827ce21b94 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -2,10 +2,7 @@ import { Request, Response } from 'express'; import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { - Integration, - Workspace, - Bot, - BotKey + Integration } from '../../models'; import { EventService } from '../../services'; import { eventPushSecrets } from '../../events'; @@ -18,6 +15,7 @@ import { eventPushSecrets } from '../../events'; */ export const createIntegration = async (req: Request, res: Response) => { let integration; + try { const { integrationAuthId, @@ -34,19 +32,19 @@ export const createIntegration = async (req: Request, res: Response) => { // TODO: validate [sourceEnvironment] and [targetEnvironment] // initialize new integration after saving integration access token - integration = await new Integration({ - workspace: req.integrationAuth.workspace._id, - environment: sourceEnvironment, - isActive, - app, + integration = await new Integration({ + workspace: req.integrationAuth.workspace._id, + environment: sourceEnvironment, + isActive, + app, appId, targetEnvironment, owner, path, region, - integration: req.integrationAuth.integration, - integrationAuth: new Types.ObjectId(integrationAuthId) - }).save(); + integration: req.integrationAuth.integration, + integrationAuth: new Types.ObjectId(integrationAuthId) + }).save(); if (integration) { // trigger event - push secrets diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index f4a87c44dd..27afbb4b16 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -229,7 +229,7 @@ const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrati // access token is expired const refreshToken = await getIntegrationAuthRefreshHelper({ integrationAuthId }); accessToken = await exchangeRefresh({ - integration: integrationAuth.integration, + integrationAuth, refreshToken }); } diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 521c51a2f0..f7eef62639 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -24,7 +24,12 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, } from "../variables"; -import { requireIntegrationAuthorizationAuth } from "../middleware"; + +interface App { + name: string; + appId?: string; + owner?: string; +} /** * Return list of names of apps for integration named [integration] @@ -45,12 +50,6 @@ const getApps = async ({ teamId?: string; }) => { - interface App { - name: string; - appId?: string; - owner?: string; - } - let apps: App[] = []; try { switch (integrationAuth.integration) { @@ -87,6 +86,7 @@ const getApps = async ({ case INTEGRATION_GITLAB: apps = await getAppsGitlab({ accessToken, + teamId }); break; case INTEGRATION_RENDER: @@ -439,44 +439,107 @@ const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => { * @returns {Object[]} apps - names of GitLab sites * @returns {String} apps.name - name of GitLab site */ -const getAppsGitlab = async ({ accessToken }: {accessToken: string}) => { - let apps; +const getAppsGitlab = async ({ + accessToken, + teamId +}: { + accessToken: string; + teamId?: string; +}) => { + const apps: App[] = []; + let page = 1; + const perPage = 10; + let hasMorePages = true; try { - const { id } = ( - await request.get( - `${INTEGRATION_GITLAB_API_URL}/v4/user`, - { - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept-Encoding": "application/json", - }, - } - ) - ).data; - const res = ( - await request.get( - `${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`, - { - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept-Encoding": "application/json", - }, + if (teamId) { + // case: fetch projects for group with id [teamId] in GitLab + + while (hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage) + }); + + const { data } = ( + await request.get( + `${INTEGRATION_GITLAB_API_URL}/v4/groups/${teamId}/projects`, + { + params, + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ) + ); + + data.map((a: any) => { + apps.push({ + name: a.name, + appId: a.id + }); + }); + + if (data.length < perPage) { + hasMorePages = false; } - ) - ).data; + + page++; + } + } else { + // case: fetch projects for individual in GitLab + + const { id } = ( + await request.get( + `${INTEGRATION_GITLAB_API_URL}/v4/user`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ) + ).data; + + while (hasMorePages) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage) + }); - apps = res?.map((a: any) => { - return { - name: a?.name, - appId: `${a?.id}`, + const { data } = ( + await request.get( + `${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`, + { + params, + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ) + ); + + data.map((a: any) => { + apps.push({ + name: a.name, + appId: a.id + }); + }); + + if (data.length < perPage) { + hasMorePages = false; + } + + page++; } - }); - }catch (err) { + } + } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error("Failed to get GitLab repos"); + throw new Error("Failed to get GitLab projects"); } return apps; diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 45ad74370e..224979c884 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -12,8 +12,7 @@ import { INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_GITLAB_TOKEN_URL, - INTEGRATION_GITLAB_API_URL + INTEGRATION_GITLAB_TOKEN_URL } from '../variables'; import { SITE_URL, @@ -169,7 +168,7 @@ const exchangeCodeAzure = async ({ accessExpiresAt.setSeconds( accessExpiresAt.getSeconds() + res.expires_in ); - } catch (err: any) { + } catch (err) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Azure'); @@ -392,46 +391,10 @@ const exchangeCodeGitlab = async ({ code }: { code: string }) => { ) ).data; - // 1. try getting groups - // https://gitlab.com/api/v4/groups - - // const res2 = (await request.get( - // `${INTEGRATION_GITLAB_API_URL}/v4/groups`, - // { - // headers: { - // Authorization: `Bearer ${res.access_token}`, - // "Accept-Encoding": "application/json" - // } - // } - // )).data; - - // 2. try getting projects of that group - // const res3 = (await request.get( - // `${INTEGRATION_GITLAB_API_URL}/v4/groups/${res2[0].id}`, - // { - // headers: { - // Authorization: `Bearer ${res.access_token}`, - // "Accept-Encoding": "application/json" - // } - // } - // )).data; - - // const res = ( - // await request.get( - // `${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`, - // { - // headers: { - // "Authorization": `Bearer ${accessToken}`, - // "Accept-Encoding": "application/json", - // }, - // } - // ) - // ).data; - accessExpiresAt.setSeconds( accessExpiresAt.getSeconds() + res.expires_in ); - }catch (err) { + } catch (err) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Gitlab'); diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 3e5bc712cd..a3aec1ebd4 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,16 +1,29 @@ import request from '../config/request'; import * as Sentry from '@sentry/node'; -import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU } from '../variables'; +import { + IIntegrationAuth +} from '../models'; +import { + INTEGRATION_AZURE_KEY_VAULT, + INTEGRATION_HEROKU, + INTEGRATION_GITLAB, +} from '../variables'; import { SITE_URL, CLIENT_ID_AZURE, + CLIENT_ID_GITLAB, CLIENT_SECRET_AZURE, - CLIENT_SECRET_HEROKU + CLIENT_SECRET_HEROKU, + CLIENT_SECRET_GITLAB } from '../config'; import { INTEGRATION_AZURE_TOKEN_URL, - INTEGRATION_HEROKU_TOKEN_URL + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_GITLAB_TOKEN_URL } from '../variables'; +import { + IntegrationService +} from '../services'; interface RefreshTokenAzureResponse { token_type: string; @@ -21,6 +34,23 @@ interface RefreshTokenAzureResponse { refresh_token: string; } +interface RefreshTokenHerokuResponse { + access_token: string; + expires_in: number; + refresh_token: string; + token_type: string; + user_id: string; +} + +interface RefreshTokenGitLabResponse { + token_type: string; + scope: string; + expires_in: number; + access_token: string; + refresh_token: string; + created_at: number; +} + /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] @@ -29,33 +59,61 @@ interface RefreshTokenAzureResponse { * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku */ const exchangeRefresh = async ({ - integration, + integrationAuth, refreshToken }: { - integration: string; + integrationAuth: IIntegrationAuth; refreshToken: string; }) => { - let accessToken; + + interface TokenDetails { + accessToken: string; + refreshToken: string; + accessExpiresAt: Date; + } + + let tokenDetails: TokenDetails; try { - switch (integration) { + switch (integrationAuth.integration) { case INTEGRATION_AZURE_KEY_VAULT: - accessToken = await exchangeRefreshAzure({ + tokenDetails = await exchangeRefreshAzure({ refreshToken }); break; case INTEGRATION_HEROKU: - accessToken = await exchangeRefreshHeroku({ + tokenDetails = await exchangeRefreshHeroku({ refreshToken }); break; + case INTEGRATION_GITLAB: + tokenDetails = await exchangeRefreshGitLab({ + refreshToken + }); + break; + default: + throw new Error('Failed to exchange token for incompatible integration'); + } + + if (tokenDetails?.accessToken && tokenDetails?.refreshToken && tokenDetails?.accessExpiresAt) { + await IntegrationService.setIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString(), + accessId: null, + accessToken: tokenDetails.accessToken, + accessExpiresAt: tokenDetails.accessExpiresAt + }); + + await IntegrationService.setIntegrationAuthRefresh({ + integrationAuthId: integrationAuth._id.toString(), + refreshToken: tokenDetails.refreshToken + }); } + + return tokenDetails.accessToken; } catch (err) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to get new OAuth2 access token'); } - - return accessToken; }; /** @@ -71,7 +129,8 @@ const exchangeRefreshAzure = async ({ refreshToken: string; }) => { try { - const res: RefreshTokenAzureResponse = (await request.post( + const accessExpiresAt = new Date(); + const { data }: { data: RefreshTokenAzureResponse } = await request.post( INTEGRATION_AZURE_TOKEN_URL, new URLSearchParams({ client_id: CLIENT_ID_AZURE, @@ -80,9 +139,17 @@ const exchangeRefreshAzure = async ({ grant_type: 'refresh_token', client_secret: CLIENT_SECRET_AZURE } as any) - )).data; + ); - return res.access_token; + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + data.expires_in + ); + + return ({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -102,10 +169,13 @@ const exchangeRefreshHeroku = async ({ }: { refreshToken: string; }) => { - - let accessToken; try { - const res = await request.post( + const accessExpiresAt = new Date(); + const { + data + }: { + data: RefreshTokenHerokuResponse + } = await request.post( INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: 'refresh_token', @@ -114,14 +184,69 @@ const exchangeRefreshHeroku = async ({ } as any) ); - accessToken = res.data.access_token; + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + data.expires_in + ); + + return ({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed to refresh OAuth2 access token for Heroku'); } +}; - return accessToken; +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * GitLab integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for GitLab + * @returns + */ +const exchangeRefreshGitLab = async ({ + refreshToken +}: { + refreshToken: string; +}) => { + try { + const accessExpiresAt = new Date(); + const { + data + }: { + data: RefreshTokenGitLabResponse + } = await request.post( + INTEGRATION_GITLAB_TOKEN_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: CLIENT_ID_GITLAB, + client_secret: CLIENT_SECRET_GITLAB, + redirect_uri: `${SITE_URL}/integrations/gitlab/oauth2/callback` + } as any), + { + headers: { + "Accept-Encoding": "application/json", + } + }); + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + data.expires_in + ); + + return ({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + accessExpiresAt + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to refresh OAuth2 access token for GitLab'); + } }; export { exchangeRefresh }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 21081294d8..829b505316 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -172,7 +172,6 @@ const syncSecretsAzureKeyVault = async ({ accessToken: string; }) => { try { - interface GetAzureKeyVaultSecret { id: string; // secret URI attributes: { @@ -1512,7 +1511,7 @@ const syncSecretsGitLab = async ({ ) ).data; - for (const key of Object.keys(secrets)) { + for await (const key of Object.keys(secrets)) { const existingSecret = getSecretsRes.find((s: any) => s.key == key); if (!existingSecret) { await request.post( @@ -1533,7 +1532,7 @@ const syncSecretsGitLab = async ({ }, } ) - }else { + } else { // udpate secret await request.put( `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${existingSecret.key}`, @@ -1553,7 +1552,7 @@ const syncSecretsGitLab = async ({ } // delete secrets - for (const sec of getSecretsRes) { + for await (const sec of getSecretsRes) { if (!(sec.key in secrets)) { await request.delete( `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${sec.key}`, diff --git a/backend/src/integrations/teams.ts b/backend/src/integrations/teams.ts index 41040aee56..9943c531ec 100644 --- a/backend/src/integrations/teams.ts +++ b/backend/src/integrations/teams.ts @@ -8,6 +8,20 @@ import { } from '../variables'; import request from '../config/request'; +interface Team { + name: string; + teamId: string; +} + +/** + * Return list of teams for integration authorization [integrationAuth] + * @param {Object} obj + * @param {String} obj.integrationAuth - integration authorization to get teams for + * @param {String} obj.accessToken - access token for integration authorization + * @returns {Object[]} teams - teams of integration authorization + * @returns {String} teams.name - name of team + * @returns {String} teams.teamId - id of team +*/ const getTeams = async ({ integrationAuth, accessToken @@ -15,10 +29,6 @@ const getTeams = async ({ integrationAuth: IIntegrationAuth; accessToken: string; }) => { - interface Team { - name: string; - teamId: string; - } let teams: Team[] = []; try { @@ -39,12 +49,20 @@ const getTeams = async ({ return teams; } +/** + * Return list of teams for GitLab integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for GitLab API + * @returns {Object[]} teams - teams that user is part of in GitLab + * @returns {String} teams.name - name of team + * @returns {String} teams.teamId - id of team +*/ const getTeamsGitLab = async ({ accessToken }: { accessToken: string; }) => { - let teams = []; + let teams: Team[] = []; try { const res = (await request.get( `${INTEGRATION_GITLAB_API_URL}/v4/groups`, diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index d5d9b80ce5..698846ea76 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -62,13 +62,11 @@ const integrationSchema = new Schema( default: null, }, appId: { - // (new) // id of app in provider type: String, default: null, }, targetEnvironment: { - // (new) // target environment type: String, default: null, diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 409422e93c..2c1f5f0452 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -64,9 +64,6 @@ router.post( integrationAuthController.saveIntegrationAccessToken ); -// this can optionally accept a teamId? -// IF teamId is passed in then it probably means that we want to get -// the apps for that team router.get( '/:integrationAuthId/apps', requireAuth({ @@ -76,7 +73,7 @@ router.get( acceptedRoles: [ADMIN, MEMBER] }), param('integrationAuthId'), - query('entity'), + query('teamId'), validateRequest, integrationAuthController.getIntegrationAuthApps ); diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index d245c5663a..7b3a20a6b8 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -7,9 +7,6 @@ import { setIntegrationAuthAccessHelper, } from '../helpers/integration'; -// should sync stuff be here too? Probably. -// TODO: move bot functions to IntegrationService. - /** * Class to handle integrations */ diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index e60436f993..56a6ee45e0 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -25,7 +25,6 @@ const fetchIntegrationAuthApps = async ({ integrationAuthId: string; teamId?: string; }) => { - console.log('fetchIntegrationAuthApps: ', integrationAuthId); const searchParams = new URLSearchParams(teamId ? { teamId } : undefined); const { data } = await apiRequest.get<{ apps: App[] }>( `/api/v1/integration-auth/${integrationAuthId}/apps`, diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index 9bd7d4ac19..488e6c53bf 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -23,8 +23,6 @@ const gitLabEntities = [ { name: 'Group', value: 'group' } ] -// TODO: flesh out the data flow... - export default function GitLabCreateIntegrationPage() { const router = useRouter(); @@ -33,59 +31,51 @@ export default function GitLabCreateIntegrationPage() { const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); - const [targetEntity, setTargetEntity] = useState(gitLabEntities[0].value); - const [targetTeam, setTargetTeam] = useState('aa'); // ? - const [targetTeamId] = useState(undefined); + const [targetTeamId, setTargetTeamId] = useState(null); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ integrationAuthId: integrationAuthId as string ?? '', ...(targetTeamId ? { teamId: targetTeamId } : {}) }); const { data: integrationAuthTeams } = useGetIntegrationAuthTeams(integrationAuthId as string ?? ''); - console.log('integrationAuthTeams: ', integrationAuthTeams); + const [targetEntity, setTargetEntity] = useState(gitLabEntities[0].value); 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); - } - + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } }, [workspace]); useEffect(() => { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { - console.log('AA'); - setTargetApp(integrationAuthApps[0].name); + setTargetAppId(integrationAuthApps[0].appId as string); } else { - console.log('BB'); - setTargetApp('none'); + setTargetAppId('none'); } } }, [integrationAuthApps]); useEffect(() => { - // if (targetEntity === 'group' && integrationAuthTeams) { - // if (integrationAuthTeams.length > 0) { - // setTargetTeam(integrationAuthTeams[0].name); - // } else { - // setTargetTeam('none'); - // } - // } - - // if (targetEntity === 'group') { - // if (integrationAuthTeams && integrationAuthTeams.length > 0) { - // setTargetTeamId(integrationAuthTeams[0].teamId); - // } else { - // setTargetTeamId(''); - // } - // } else { - - // } - }, [integrationAuthTeams, integrationAuthApps, targetEntity]); + if (targetEntity === 'group' && integrationAuthTeams && integrationAuthTeams.length > 0) { + if (integrationAuthTeams) { + if (integrationAuthTeams.length > 0) { + // case: user is part of at least 1 group in GitLab + setTargetTeamId(integrationAuthTeams[0].teamId); + } else { + // case: user is not part of any groups in GitLab + setTargetTeamId('none'); + } + } + } else if (targetEntity === 'individual') { + setTargetTeamId(null); + } + }, [targetEntity, integrationAuthTeams]); const handleButtonClick = async () => { try { @@ -95,8 +85,8 @@ export default function GitLabCreateIntegrationPage() { await createIntegration({ integrationAuthId: integrationAuth?._id, isActive: true, - app: targetApp, - appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, + app: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId))?.name ?? null, + appId: targetAppId, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, owner: null, @@ -113,15 +103,7 @@ export default function GitLabCreateIntegrationPage() { } } - console.log('A', (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && integrationAuthTeams && targetApp && targetTeam)); - console.log('B', integrationAuth); - console.log('C', workspace); - console.log('D', selectedSourceEnvironment); - console.log('E', integrationAuthApps); - console.log('F', integrationAuthTeams); - console.log('G', targetApp); - console.log('H', targetTeam); - return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && integrationAuthTeams && targetApp && targetTeam) ? ( + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && integrationAuthTeams && targetAppId) ? (
GitLab Integration @@ -165,13 +147,13 @@ export default function GitLabCreateIntegrationPage() { className='mt-4' > setTargetApp(val)} + value={targetAppId} + onValueChange={(val) => setTargetAppId(val)} className='w-full border border-mineshaft-500' isDisabled={integrationAuthApps.length === 0} > {integrationAuthApps.length > 0 ? ( integrationAuthApps.map((integrationAuthApp) => ( - + {integrationAuthApp.name} )) From 78cb18ad0e0743bb1f9e3204b73f120489ff5df4 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 10 Mar 2023 21:45:51 +0700 Subject: [PATCH 3/3] Fix lint errors --- frontend/src/pages/integrations/gitlab/create.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index 488e6c53bf..259285109b 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -141,7 +141,7 @@ export default function GitLabCreateIntegrationPage() { })} - {targetEntity === 'group' && ( + {targetEntity === 'group' && targetTeamId && (