From f0e8781d5ed27b9c53c6ff97b4ac2cd390c88639 Mon Sep 17 00:00:00 2001 From: X_AUTHOR_NAME Date: Mon, 22 Mar 2021 05:11:10 +0000 Subject: [PATCH] [dashboard] Add Git Providers --- components/dashboard/src/App.tsx | 4 +- components/dashboard/src/Login.tsx | 27 +- .../dashboard/src/components/ContextMenu.tsx | 7 +- components/dashboard/src/components/Modal.tsx | 2 +- components/dashboard/src/provider-utils.tsx | 28 ++ .../dashboard/src/service/service-mock.ts | 18 ++ .../src/settings/GitIntegrations.tsx | 9 - .../dashboard/src/settings/Integrations.tsx | 300 ++++++++++++++++++ 8 files changed, 355 insertions(+), 40 deletions(-) create mode 100644 components/dashboard/src/provider-utils.tsx delete mode 100644 components/dashboard/src/settings/GitIntegrations.tsx create mode 100644 components/dashboard/src/settings/Integrations.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 2bc423be93cb15..db92421018eb5c 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -14,7 +14,7 @@ const Account = React.lazy(() => import(/* webpackPrefetch: true */ './settings/ const Notifications = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Notifications')); const Plans = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Plans')); const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ './settings/EnvironmentVariables')); -const GitIntegrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/GitIntegrations')); +const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Integrations')); const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences')); function Loading() { @@ -70,10 +70,10 @@ function App() { } /> + - )} diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index fc3e100db2ed00..503331a3fe5f67 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -2,6 +2,7 @@ import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; import { useContext, useEffect, useState } from "react"; import { UserContext } from "./user-context"; import { getGitpodService, gitpodHostUrl, reconnectGitpodService } from "./service/service"; +import { iconForAuthProvider, simplifyProviderName } from "./provider-utils"; export function Login() { const { setUser } = useContext(UserContext); @@ -92,32 +93,6 @@ export function Login() { ); } -function iconForAuthProvider(type: string) { - switch (type) { - case "GitHub": - return "/images/github.svg" - case "GitLab": - return "/images/gitlab.svg" - case "BitBucket": - return "/images/bitbucket.svg" - default: - break; - } -} - -function simplifyProviderName(host: string) { - switch (host) { - case "github.com": - return "GitHub" - case "gitlab.com": - return "GitLab" - case "bitbucket.org": - return "BitBucket" - default: - return host; - } -} - function getLoginUrl(host: string) { const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString(); return gitpodHostUrl.withApi({ diff --git a/components/dashboard/src/components/ContextMenu.tsx b/components/dashboard/src/components/ContextMenu.tsx index c196a06826b0b6..061d78b9037f08 100644 --- a/components/dashboard/src/components/ContextMenu.tsx +++ b/components/dashboard/src/components/ContextMenu.tsx @@ -43,6 +43,9 @@ function ContextMenu(props: ContextMenuProps) { }) const font = "text-gray-600 hover:text-gray-800" + + const menuId = String(Math.random()); + return (
{ @@ -53,7 +56,7 @@ function ContextMenu(props: ContextMenuProps) {
{expanded?
- {enhancedEntries.map(e => { + {enhancedEntries.map((e, index) => { const clickable = e.href || e.onClick; const entry =
{e.title}
{e.active ?
: null} @@ -61,7 +64,7 @@ function ContextMenu(props: ContextMenuProps) { if (!clickable) { return entry; } - return + return {entry} })} diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index 0023e1bde4423c..970434e494a3ed 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -2,7 +2,7 @@ import { Disposable, DisposableCollection } from "@gitpod/gitpod-protocol"; import { useEffect } from "react"; export default function Modal(props: { - children: React.ReactChild[] | React.ReactChild, + children: React.ReactChild[] | React.ReactChild | undefined, visible: boolean, closeable?: boolean, className?: string, diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx new file mode 100644 index 00000000000000..046fad121bdd14 --- /dev/null +++ b/components/dashboard/src/provider-utils.tsx @@ -0,0 +1,28 @@ + +function iconForAuthProvider(type: string) { + switch (type) { + case "GitHub": + return "/images/github.svg" + case "GitLab": + return "/images/gitlab.svg" + case "BitBucket": + return "/images/bitbucket.svg" + default: + break; + } +} + +function simplifyProviderName(host: string) { + switch (host) { + case "github.com": + return "GitHub" + case "gitlab.com": + return "GitLab" + case "bitbucket.org": + return "BitBucket" + default: + return host; + } +} + +export { iconForAuthProvider, simplifyProviderName } \ No newline at end of file diff --git a/components/dashboard/src/service/service-mock.ts b/components/dashboard/src/service/service-mock.ts index fe2eaa2440f554..ea117586012a14 100644 --- a/components/dashboard/src/service/service-mock.ts +++ b/components/dashboard/src/service/service-mock.ts @@ -41,6 +41,24 @@ const gitpodServiceMock = createServiceMock({ "isReadonly": false }] }, + getOwnAuthProviders: async () => { + return [{ + "id": "foobar123", + "ownerId": "1234", + "status": "verified", + "host": "testing.doptig.com/gitlab", + "type": "GitLab", + "oauth": { + "authorizationUrl": "https://testing.doptig.com/gitlab/oauth/authorize", + "tokenUrl": "https://testing.doptig.com/gitlab/oauth/token", + "settingsUrl": "https://testing.doptig.com/gitlab/profile/applications", + "callBackUrl": "https://gitpod-staging.com/auth/testing.doptig.com/gitlab/callback", + "clientId": "clientid-123", + "clientSecret": "redacted" + }, + "deleted": false + }] + }, onDidOpenConnection: Event.None, onDidCloseConnection: Event.None, diff --git a/components/dashboard/src/settings/GitIntegrations.tsx b/components/dashboard/src/settings/GitIntegrations.tsx deleted file mode 100644 index f642103fad729f..00000000000000 --- a/components/dashboard/src/settings/GitIntegrations.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SettingsPage } from "./SettingsPage"; - -export default function GitIntegrations() { - return
- -

Git Hoster Access Control

-
-
; -} \ No newline at end of file diff --git a/components/dashboard/src/settings/Integrations.tsx b/components/dashboard/src/settings/Integrations.tsx new file mode 100644 index 00000000000000..fb761c648122ca --- /dev/null +++ b/components/dashboard/src/settings/Integrations.tsx @@ -0,0 +1,300 @@ +import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; +import React, { useContext, useEffect, useState } from "react"; +import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu"; +import { SettingsPage } from "./SettingsPage"; +import { getGitpodService, gitpodHostUrl } from "../service/service"; +import { UserContext } from "../user-context"; +import ThreeDots from '../icons/ThreeDots.svg'; +import Modal from "../components/Modal"; + +export default function Integrations() { + + return (
+ + + +
); +} + + +function GitProviders() { + + const { user, setUser } = useContext(UserContext); + + const [authProviders, setAuthProviders] = useState([]); + const [allScopes, setAllScopes] = useState>(new Map()); + const [diconnectModal, setDisconnectModal] = useState<{ provider: AuthProviderInfo } | undefined>(undefined); + const [editModal, setEditModal] = useState<{ provider: AuthProviderInfo, prevScopes: Set, nextScopes: Set } | undefined>(undefined); + + useEffect(() => { + updateAuthProviders(); + }, []); + + useEffect(() => { + updateCurrentScopes(); + }, [user, authProviders]); + + const updateAuthProviders = async () => { + setAuthProviders(await getGitpodService().server.getAuthProviders()); + } + + const updateCurrentScopes = async () => { + if (user) { + const scopesByProvider = new Map(); + const connectedProviders = user.identities.map(i => authProviders.find(ap => ap.authProviderId === i.authProviderId)); + for (let provider of connectedProviders) { + if (!provider) { + continue; + } + const token = await getGitpodService().server.getToken({ host: provider.host }); + scopesByProvider.set(provider.authProviderId, (token?.scopes?.slice() || [])); + } + setAllScopes(scopesByProvider); + } + } + + const isConnected = (authProviderId: string) => { + return !!user?.identities?.find(i => i.authProviderId === authProviderId); + }; + + const gitProviderMenu = (provider: AuthProviderInfo) => { + const result: ContextMenuEntry[] = []; + const connected = isConnected(provider.authProviderId); + if (connected) { + result.push({ + title: 'Edit Permissions', + onClick: () => startEditPermissions(provider), + separator: true, + }); + result.push( { + title: 'Disconnect', + customFontStyle: 'text-red-600', + onClick: () => setDisconnectModal({ provider }) + }) + } else { + result.push( { + title: 'Connect', + customFontStyle: 'text-green-600', + onClick: () => connect(provider) + }) + } + return result; + }; + + + + const getUsername = (authProviderId: string) => { + return user?.identities?.find(i => i.authProviderId === authProviderId)?.authName; + }; + + const getPermissions = (authProviderId: string) => { + return allScopes.get(authProviderId); + }; + + const connect = async (ap: AuthProviderInfo) => { + const thisUrl = gitpodHostUrl; + const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString(); + const url = thisUrl.withApi({ + pathname: '/authorize', + search: `returnTo=${returnTo}&host=${ap.host}&override=true&scopes=${(ap.requirements?.default || []).join(',')}` + }).toString(); + const newWindow = window.open(url, "gitpod-connect"); + if (!newWindow) { + console.log(`Failed to open authorize window for ${ap.host}`); + } + + await openAuthWindow(ap); + } + + const disconnect = async (ap: AuthProviderInfo) => { + setDisconnectModal(undefined); + const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString(); + const deauthorizeUrl = gitpodHostUrl.withApi({ + pathname: '/deauthorize', + search: `returnTo=${returnTo}&host=${ap.host}` + }).toString(); + + try { + await fetch(deauthorizeUrl); + console.log(`Deauthorized for ${ap.host}`); + + updateUser(); + } catch (error) { + console.log(`Failed to deauthorize for ${ap.host}`); + } + } + + const startEditPermissions = async (provider: AuthProviderInfo) => { + // todo: add spinner + + const token = await getGitpodService().server.getToken({ host: provider.host }); + if (token) { + setEditModal({ provider, prevScopes: new Set(token.scopes), nextScopes: new Set(token.scopes) }); + } + } + + const updateUser = async () => { + const user = await getGitpodService().server.getLoggedInUser(); + setUser(user); + } + + const openAuthWindow = async (ap: AuthProviderInfo, scopes?: string[]) => { + const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString(); + const url = gitpodHostUrl.withApi({ + pathname: '/authorize', + search: `returnTo=${encodeURIComponent(returnTo)}&host=${ap.host}&override=true&scopes=${(scopes || ap.requirements?.default || []).join(',')}` + }).toString(); + const newWindow = window.open(url, "gitpod-connect"); + if (!newWindow) { + console.log(`Failed to open the authorize window for ${ap.host}`); + } + + const eventListener = (event: MessageEvent) => { + // todo: check event.origin + + if (event.data === "auth-success") { + window.removeEventListener("message", eventListener); + + if (event.source && "close" in event.source && event.source.close) { + console.log(`try to close window`); + event.source.close(); + } else { + // todo: add a button to the /login-success page to close, if this should not work as expected + } + updateUser(); + } + }; + + window.addEventListener("message", eventListener); + } + + const updatePermissions = async () => { + if (!editModal) { + return; + } + try { + await openAuthWindow(editModal.provider, Array.from(editModal.nextScopes)); + } catch (error) { + console.log(error); + } + setEditModal(undefined); + } + const onChangeScopeHandler = (e: React.ChangeEvent) => { + if (!editModal) { + return; + } + const scope = e.target.name; + const nextScopes = new Set(editModal.nextScopes); + if (e.target.checked) { + nextScopes.add(scope); + } else { + nextScopes.delete(scope); + } + setEditModal({ ...editModal, nextScopes }); + } + + return (
+ setDisconnectModal(undefined)}> +

You are about to disconnect {diconnectModal?.provider.host}

+
+ +
+
+ + setEditModal(undefined)}> + {editModal && ( +
+

Permissions granted

+
+ {editModal && editModal.provider.scopes!.map(scope => ( +
+ +
+ ))} +
+
+ +
+
+ )} +
+ +

Git Providers

+

Manage permissions for git providers.

+
+ {authProviders && authProviders.map(ap => ( +
+
+
+   +
+
+
+ {ap.authProviderType} + {ap.host} +
+
+ {getUsername(ap.authProviderId) || "–"} + Username +
+
+ {getPermissions(ap.authProviderId)?.join(", ") || "–"} + Permissions +
+
+ + Actions + +
+
+ ))} +
+
); +} + +function CheckBox(props: { + name?: string, + title: string, + desc: string, + checked: boolean, + disabled?: boolean, + onChange: (e: React.ChangeEvent) => void +}) { + const inputProps: React.InputHTMLAttributes = { + checked: props.checked, + disabled: props.disabled, + onChange: props.onChange, + }; + if (props.name) { + inputProps.name = props.name; + } + + const checkboxId = `checkbox-${props.title}-${String(Math.random())}`; + + return
+ +
+ +
{props.desc}
+
+
+} + +function equals(a: Set, b: Set): boolean { + return a.size === b.size && Array.from(a).every(e => b.has(e)); +} \ No newline at end of file