From 40112700f95fab262b896b60b2f0e41b419df710 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Fri, 26 Mar 2021 13:32:57 +0000 Subject: [PATCH] Add Git Integrations closes https://github.com/gitpod-io/gitpod/issues/3333 --- components/dashboard/src/Login.tsx | 8 +- .../dashboard/src/components/CheckBox.tsx | 39 ++ components/dashboard/src/images.ts | 6 +- components/dashboard/src/images/copy.svg | 4 + .../dashboard/src/images/exclamation.svg | 3 + components/dashboard/src/provider-utils.tsx | 44 +- .../dashboard/src/settings/Integrations.tsx | 551 ++++++++++++++---- .../dashboard/src/settings/Notifications.tsx | 11 +- components/dashboard/src/tailwind.output.css | 30 + components/dashboard/tailwind.config.js | 1 + .../gitpod-protocol/src/gitpod-service.ts | 2 +- components/gitpod-protocol/src/protocol.ts | 11 +- .../server/src/auth/auth-provider-service.ts | 12 +- .../src/workspace/gitpod-server-impl.ts | 17 +- 14 files changed, 585 insertions(+), 154 deletions(-) create mode 100644 components/dashboard/src/components/CheckBox.tsx create mode 100644 components/dashboard/src/images/copy.svg create mode 100644 components/dashboard/src/images/exclamation.svg diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index 196db0cc331c2a..2ad4c42bc6a698 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -4,7 +4,7 @@ * See License-AGPL.txt in the project root for license information. */ -import * as images from './images'; +import { gitpod, gitpodIcon, terminal } from './images'; import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; import { useContext, useEffect, useState } from "react"; import { UserContext } from "./user-context"; @@ -52,7 +52,7 @@ export function Login() {
- +

Save Time
with Prebuilds

@@ -61,7 +61,7 @@ export function Login() { Gitpod continuously builds your git branches like a CI server. This means no more waiting for dependencies to be downloaded and builds to finish. Learn more about Prebuilds
- +
@@ -71,7 +71,7 @@ export function Login() {
- +

Log in to Gitpod

diff --git a/components/dashboard/src/components/CheckBox.tsx b/components/dashboard/src/components/CheckBox.tsx new file mode 100644 index 00000000000000..9dd6116add2e33 --- /dev/null +++ b/components/dashboard/src/components/CheckBox.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2021 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + + +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}
+
+
+} + +export default CheckBox; \ No newline at end of file diff --git a/components/dashboard/src/images.ts b/components/dashboard/src/images.ts index 59e13181196c79..d21e0db87c94c7 100644 --- a/components/dashboard/src/images.ts +++ b/components/dashboard/src/images.ts @@ -12,6 +12,8 @@ import gitpod from './images/gitpod.svg'; import gitpodIcon from './icons/gitpod.svg'; import theia from './images/theia-gray.svg'; import vscode from './images/vscode.svg'; +import copy from './images/copy.svg'; +import exclamation from './images/exclamation.svg'; export { github, @@ -21,5 +23,7 @@ export { gitpod, gitpodIcon, theia, - vscode + vscode, + copy, + exclamation, } diff --git a/components/dashboard/src/images/copy.svg b/components/dashboard/src/images/copy.svg new file mode 100644 index 00000000000000..104644d947e49c --- /dev/null +++ b/components/dashboard/src/images/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/components/dashboard/src/images/exclamation.svg b/components/dashboard/src/images/exclamation.svg new file mode 100644 index 00000000000000..288106777648d4 --- /dev/null +++ b/components/dashboard/src/images/exclamation.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx index c3fa22156d8f79..66185d0c823809 100644 --- a/components/dashboard/src/provider-utils.tsx +++ b/components/dashboard/src/provider-utils.tsx @@ -4,16 +4,18 @@ * See License-AGPL.txt in the project root for license information. */ -import * as images from './images'; +import {github, gitlab, bitbucket} from './images'; +import { gitpodHostUrl } from "./service/service"; + function iconForAuthProvider(type: string) { switch (type) { case "GitHub": - return images.github + return github case "GitLab": - return images.gitlab + return gitlab case "Bitbucket": - return images.bitbucket + return bitbucket default: break; } @@ -32,4 +34,36 @@ function simplifyProviderName(host: string) { } } -export { iconForAuthProvider, simplifyProviderName } \ No newline at end of file +async function openAuthorizeWindow({ host, scopes, onSuccess, onError }: { host: string, scopes?: string[], onSuccess?: () => void, onError?: (error?: string) => void }) { + const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString(); + const url = gitpodHostUrl.withApi({ + pathname: '/authorize', + search: `returnTo=${encodeURIComponent(returnTo)}&host=${host}&override=true&scopes=${(scopes || []).join(',')}` + }).toString(); + + const newWindow = window.open(url, "gitpod-connect"); + if (!newWindow) { + console.log(`Failed to open the authorize window for ${host}`); + onError && onError("failed"); + return; + } + + 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(`Authorization OK. Closing child window.`); + event.source.close(); + } else { + // todo: add a button to the /login-success page to close, if this should not work as expected + } + onSuccess && onSuccess(); + } + }; + window.addEventListener("message", eventListener); +} + +export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow } \ No newline at end of file diff --git a/components/dashboard/src/settings/Integrations.tsx b/components/dashboard/src/settings/Integrations.tsx index 187d2dbba89b51..ec182fd7aab606 100644 --- a/components/dashboard/src/settings/Integrations.tsx +++ b/components/dashboard/src/settings/Integrations.tsx @@ -4,7 +4,8 @@ * See License-AGPL.txt in the project root for license information. */ -import { AuthProviderInfo } from "@gitpod/gitpod-protocol"; +import { copy, exclamation } from '../images'; +import { AuthProviderEntry, AuthProviderInfo } from "@gitpod/gitpod-protocol"; import React, { useContext, useEffect, useState } from "react"; import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu"; import { SettingsPage } from "./SettingsPage"; @@ -12,12 +13,16 @@ import { getGitpodService, gitpodHostUrl } from "../service/service"; import { UserContext } from "../user-context"; import ThreeDots from '../icons/ThreeDots.svg'; import Modal from "../components/Modal"; +import { openAuthorizeWindow } from "../provider-utils"; +import CheckBox from '../components/CheckBox'; export default function Integrations() { return (
- + +
+
); } @@ -70,13 +75,25 @@ function GitProviders() { result.push({ title: 'Edit Permissions', onClick: () => startEditPermissions(provider), - separator: true, + separator: !provider.settingsUrl, }); - result.push({ - title: 'Disconnect', - customFontStyle: 'text-red-600', - onClick: () => setDisconnectModal({ provider }) - }) + if (provider.settingsUrl) { + result.push({ + title: `Manage on ${provider.host}`, + onClick: () => { + window.open(provider.settingsUrl, "_blank", "noopener,noreferrer"); + }, + separator: true, + }); + } + const connectedWithSecondProvider = authProviders.some(p => p.authProviderId !== provider.authProviderId && isConnected(p.authProviderId)) + if (connectedWithSecondProvider) { + result.push({ + title: 'Disconnect', + customFontStyle: 'text-red-600', + onClick: () => setDisconnectModal({ provider }) + }); + } } else { result.push({ title: 'Connect', @@ -87,8 +104,6 @@ function GitProviders() { return result; }; - - const getUsername = (authProviderId: string) => { return user?.identities?.find(i => i.authProviderId === authProviderId)?.authName; }; @@ -98,18 +113,7 @@ function GitProviders() { }; 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); + await doAuthorize(ap.host, ap.requirements?.default); } const disconnect = async (ap: AuthProviderInfo) => { @@ -144,34 +148,12 @@ function GitProviders() { 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 doAuthorize = async (host: string, scopes?: string[]) => { + try { + await openAuthorizeWindow({ host, scopes, onSuccess: () => updateUser() }); + } catch (error) { + console.log(error) } - - 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 () => { @@ -179,7 +161,7 @@ function GitProviders() { return; } try { - await openAuthWindow(editModal.provider, Array.from(editModal.nextScopes)); + await doAuthorize(editModal.provider.host, Array.from(editModal.nextScopes)); } catch (error) { console.log(error); } @@ -199,70 +181,107 @@ function GitProviders() { setEditModal({ ...editModal, nextScopes }); } + const getDescriptionForScope = (scope: string) => { + switch (scope) { + case "user:email": return "Read-only access to your email addresses"; + case "read:user": return "Read-only access to your profile information"; + case "public_repo": return "Write access to code in public repositories and organizations"; + case "repo": return "Read/write access to code in private repositories and organizations"; + case "read:org": return "Read-only access to organizations (used to suggest organizations when forking a repository)"; + case "workflow": return "Allow updating GitHub Actions workflow files"; + // GitLab + case "read_user": return "Read-only access to your email addresses"; + case "api": return "Allow making API calls (used to set up a webhook when enabling prebuilds for a repository)"; + case "read_repository": return "Read/write access to your repositories"; + // Bitbucket + case "account": return "Read-only access to your account information"; + case "repository": return "Read-only access to your repositories (note: Bitbucket doesn't support revoking scopes)"; + case "repository:write": return "Read/write access to your repositories (note: Bitbucket doesn't support revoking scopes)"; + case "pullrequest": return "Read access to pull requests and ability to collaborate via comments, tasks, and approvals (note: Bitbucket doesn't support revoking scopes)"; + case "pullrequest:write": return "Allow creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)"; + case "webhook": return "Allow installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)"; + default: return ""; + } + } + return (
- setDisconnectModal(undefined)}> -

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

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

Permissions granted

-
- {editModal && editModal.provider.scopes!.map(scope => ( -
- -
- ))} + + {diconnectModal && ( + setDisconnectModal(undefined)}> +

Disconnect Provider

+
+

Are you sure you want to disconnect the following provider?

+ +
+
{diconnectModal.provider.authProviderType}
+
{diconnectModal.provider.host}
+
+
+
+ +
+
+ )} + + {editModal && ( + setEditModal(undefined)}> +

Edit Permissions

+
+
+ Configure provider permissions.
-
-
+
+ -
- )} -
-
+
+ + )}

Git Providers

Manage permissions for git providers.

{authProviders && authProviders.map(ap => (
-
+
 
-
+
{ap.authProviderType} {ap.host}
{getUsername(ap.authProviderId) || "–"} - Username + Username
-
+
{getPermissions(ap.authProviderId)?.join(", ") || "–"} - Permissions + Permissions
-
- - Actions - +
+
+ + Actions + +
))} @@ -270,35 +289,333 @@ function GitProviders() {
); } -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, +function GitIntegrations() { + + const { user } = useContext(UserContext); + + const [providers, setProviders] = useState([]); + + const [modal, setModal] = useState<{ mode: "new" } | { mode: "edit", provider: AuthProviderEntry } | { mode: "delete", provider: AuthProviderEntry } | undefined>(undefined); + + useEffect(() => { + updateOwnAuthProviders(); + }, []); + + const updateOwnAuthProviders = async () => { + setProviders(await getGitpodService().server.getOwnAuthProviders()); + } + + const deleteProvider = async (provider: AuthProviderEntry) => { + try { + await getGitpodService().server.deleteOwnAuthProvider(provider); + } catch (error) { + console.log(error); + } + setModal(undefined); + updateOwnAuthProviders(); + } + + const gitProviderMenu = (provider: AuthProviderEntry) => { + const result: ContextMenuEntry[] = []; + result.push({ + title: provider.status === "verified" ? "Edit Configuration" : "Activate Integration", + onClick: () => setModal({ mode: "edit", provider }), + separator: true, + }) + result.push({ + title: 'Remove', + customFontStyle: 'text-red-600', + onClick: () => setModal({ mode: "delete", provider }) + }); + return result; }; - if (props.name) { - inputProps.name = props.name; + + return (
+ + {modal?.mode === "new" && ( + setModal(undefined)} update={updateOwnAuthProviders} /> + )} + {modal?.mode === "edit" && ( + setModal(undefined)} update={updateOwnAuthProviders} /> + )} + {modal?.mode === "delete" && ( + setModal(undefined)}> +

Remove Integration

+
+

Are you sure you want to remove the following git integration?

+ +
+
{modal.provider.type}
+
{modal.provider.host}
+
+
+
+ +
+
+ )} + +

Git Integrations

+

Manage git integrations for GitLab or GitHub self-hosted instances.

+ + {providers && providers.length === 0 && ( +
+
+

No Git Integrations

+
In addition to the default Git Providers you can authorize
with a self hosted instace of a provider.
+ +
+
+ )} +
+ {providers && providers.map(ap => ( +
+ +
+
+   +
+
+
+ {ap.type} +
+
+ {ap.host} +
+
+
+ + Actions + +
+
+
+ ))} +
+ {providers && providers.length > 0 && ( +
+ +
+ )} +
); +} + +function GitIntegrationModal(props: ({ + mode: "new", +} | { + mode: "edit", + provider: AuthProviderEntry +}) & { + userId: string, + onClose?: () => void + update?: () => void +}) { + + const callbackUrl = (host: string) => { + const pathname = `/auth/${host}/callback`; + return gitpodHostUrl.with({ pathname }).toString(); } - const checkboxId = `checkbox-${props.title}-${String(Math.random())}`; + const [type, setType] = useState("GitLab"); + const [host, setHost] = useState("gitlab.example.com"); + const [redirectURL, setRedirectURL] = useState(callbackUrl("gitlab.example.com")); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [busy, setBusy] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const [validationError, setValidationError] = useState(); + + useEffect(() => { + if (props.mode === "edit") { + setType(props.provider.type); + setHost(props.provider.host); + setClientId(props.provider.oauth.clientId); + setClientSecret(props.provider.oauth.clientSecret); + setRedirectURL(props.provider.oauth.callBackUrl); + } + }, []); + + useEffect(() => { + validate(); + }, [clientId, clientSecret]) + + const close = () => props.onClose && props.onClose(); + const updateList = () => props.update && props.update(); + + const activate = async () => { + let entry = (props.mode === "new") ? { + host, + type, + clientId, + clientSecret, + ownerId: props.userId + } as AuthProviderEntry.NewEntry : { + id: props.provider.id, + ownerId: props.userId, + clientId, + clientSecret: clientSecret === "redacted" ? undefined : clientSecret + } as AuthProviderEntry.UpdateEntry; + + setBusy(true); + setErrorMessage(undefined); + try { + const newProvider = await getGitpodService().server.updateOwnAuthProvider({ entry }); + + // the server is checking periodically for updates of dynamic providers, thus we need to + // wait at least 2 seconds for the changes to be propagated before we try to use this provider. + await new Promise(resolve => setTimeout(resolve, 2000)); + + updateList(); - return
- -
- -
{props.desc}
+ // just open the authorization window and do *not* await + openAuthorizeWindow({ host: newProvider.host, onSuccess: updateList }); + + // close the modal, as the creation phase is done anyways. + close(); + } catch (error) { + console.log(error); + setErrorMessage("message" in error ? error.message : "Failed to update Git provider"); + } + setBusy(false); + } + + const updateHostValue = (host: string) => { + if (props.mode === "new") { + setHost(host); + setRedirectURL(callbackUrl(host)); + setErrorMessage(undefined); + } + } + + const updateClientId = (value: string) => { + setClientId(value); + } + const updateClientSecret = (value: string) => { + setClientSecret(value); + } + + const validate = () => { + const errors: string[] = []; + if (clientId.trim().length === 0) { + errors.push("Client ID is missing."); + } + if (clientSecret.trim().length === 0) { + errors.push("Client Secret is missing."); + } + if (errors.length === 0) { + setValidationError(undefined); + return true; + } else { + setValidationError(errors.join("\n")); + return false; + } + } + + const getRedirectUrlDescription = (type: string, host: string) => { + let settingsUrl = ``; + switch (type) { + case "GitHub": + settingsUrl = `${host}/settings/developers`; + break; + case "GitLab": + settingsUrl = `${host}/profile/applications`; + break; + default: return undefined; + } + let docsUrl = ``; + switch (type) { + case "GitHub": + docsUrl = `https://www.gitpod.io/docs/github-integration/#oauth-application`; + break; + case "GitLab": + docsUrl = `https://www.gitpod.io/docs/gitlab-integration/#oauth-application`; + break; + default: return undefined; + } + + return ( + Use this redirect URL to update the OAuth application. + Go to developer settings and setup the OAuth application.  + Learn more. + ); + } + + const copyRedirectUrl = () => { + const el = document.createElement("textarea"); + el.value = redirectURL; + document.body.appendChild(el); + el.select(); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(el); + } + }; + + return ( +

{props.mode === "new" ? "New Git Integration" : "Git Integration"}

+
+ {props.mode === "edit" && props.provider.status === "pending" && ( +
+ + You need to activate this integration. +
+ )} +
+ Configure a git integration with a GitLab or GitHub self-hosted instance. +
+ {props.mode === "new" && ( +
+ + +
+ )} +
+ + updateHostValue(e.target.value)} /> +
+
+ +
+ +
copyRedirectUrl()}> + +
+
+ {getRedirectUrlDescription(type, host)} +
+
+ + updateClientId(e.target.value)} /> +
+
+ + updateClientSecret(e.target.value)} /> +
+ {errorMessage && ( +
+ + {errorMessage} +
+ )} + {!!validationError && ( +
+ + {validationError} +
+ )} +
+
+
-
+ ); } function equals(a: Set, b: Set): boolean { diff --git a/components/dashboard/src/settings/Notifications.tsx b/components/dashboard/src/settings/Notifications.tsx index 2c8b7ef1221b03..18c9836e60f1f5 100644 --- a/components/dashboard/src/settings/Notifications.tsx +++ b/components/dashboard/src/settings/Notifications.tsx @@ -8,6 +8,7 @@ import { useContext } from "react"; import { SettingsPage } from "./SettingsPage"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; +import CheckBox from "../components/CheckBox"; export default function Notifications() { const ctx = useContext(UserContext); @@ -56,13 +57,3 @@ export default function Notifications() {
; } - -function CheckBox(props: {title: string, desc: string, checked: boolean, onChange: () => void}) { - return
- -
-
{props.title}
-
{props.desc}
-
-
-} diff --git a/components/dashboard/src/tailwind.output.css b/components/dashboard/src/tailwind.output.css index ac6c0653d1b3ba..52341866ad7fd6 100644 --- a/components/dashboard/src/tailwind.output.css +++ b/components/dashboard/src/tailwind.output.css @@ -34735,6 +34735,11 @@ input[disabled] { filter: grayscale(1) !important; } +.filter-brightness-10 { + -webkit-filter: brightness(10) !important; + filter: brightness(10) !important; +} + @media (min-width: 640px) { .sm\:container { width: 100%; @@ -67940,6 +67945,11 @@ input[disabled] { -webkit-filter: grayscale(1) !important; filter: grayscale(1) !important; } + + .sm\:filter-brightness-10 { + -webkit-filter: brightness(10) !important; + filter: brightness(10) !important; + } } @media (min-width: 768px) { @@ -101147,6 +101157,11 @@ input[disabled] { -webkit-filter: grayscale(1) !important; filter: grayscale(1) !important; } + + .md\:filter-brightness-10 { + -webkit-filter: brightness(10) !important; + filter: brightness(10) !important; + } } @media (min-width: 1024px) { @@ -134354,6 +134369,11 @@ input[disabled] { -webkit-filter: grayscale(1) !important; filter: grayscale(1) !important; } + + .lg\:filter-brightness-10 { + -webkit-filter: brightness(10) !important; + filter: brightness(10) !important; + } } @media (min-width: 1280px) { @@ -167561,6 +167581,11 @@ input[disabled] { -webkit-filter: grayscale(1) !important; filter: grayscale(1) !important; } + + .xl\:filter-brightness-10 { + -webkit-filter: brightness(10) !important; + filter: brightness(10) !important; + } } @media (min-width: 1536px) { @@ -200768,4 +200793,9 @@ input[disabled] { -webkit-filter: grayscale(1) !important; filter: grayscale(1) !important; } + + .\32xl\:filter-brightness-10 { + -webkit-filter: brightness(10) !important; + filter: brightness(10) !important; + } } \ No newline at end of file diff --git a/components/dashboard/tailwind.config.js b/components/dashboard/tailwind.config.js index 1aeb5e4e5a6b34..6cc9e4c5c3395f 100644 --- a/components/dashboard/tailwind.config.js +++ b/components/dashboard/tailwind.config.js @@ -63,6 +63,7 @@ module.exports = { // https://github.com/benface/tailwindcss-filters#usage 'none': 'none', 'grayscale': 'grayscale(1)', + 'brightness-10': 'brightness(10)', }, }, variants: { diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 5bd45dfec1942a..15edb2355b3f7e 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -45,7 +45,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, updateLoggedInUser(user: Partial): Promise; getAuthProviders(): Promise; getOwnAuthProviders(): Promise; - updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise; + updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise; deleteOwnAuthProvider(params: GitpodServer.DeleteOwnAuthProviderParams): Promise; getBranding(): Promise; getConfiguration(): Promise; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index fd01642fd4d668..c6e76045376c8c 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -1062,8 +1062,17 @@ export interface OAuth2Config { export namespace AuthProviderEntry { export type Type = "GitHub" | "GitLab" | string; export type Status = "pending" | "verified"; - export type NewEntry = Pick; + export type NewEntry = Pick & { clientId?: string, clientSecret?: string }; export type UpdateEntry = Pick & Pick; + export function redact(entry: AuthProviderEntry): AuthProviderEntry { + return { + ...entry, + oauth: { + ...entry.oauth, + clientSecret: "redacted" + } + } + } } export interface Branding { diff --git a/components/server/src/auth/auth-provider-service.ts b/components/server/src/auth/auth-provider-service.ts index 1c3b25b6380eb0..7ea3ba0b0cf17b 100644 --- a/components/server/src/auth/auth-provider-service.ts +++ b/components/server/src/auth/auth-provider-service.ts @@ -64,7 +64,7 @@ export class AuthProviderService { await this.authProviderDB.delete(authProvider); } - async updateAuthProvider(entry: AuthProviderEntry.UpdateEntry | AuthProviderEntry.NewEntry): Promise { + async updateAuthProvider(entry: AuthProviderEntry.UpdateEntry | AuthProviderEntry.NewEntry): Promise { let authProvider: AuthProviderEntry; if ("id" in entry) { const { id, ownerId } = entry; @@ -76,7 +76,7 @@ export class AuthProviderService { || (entry.clientSecret && entry.clientSecret !== existing.oauth.clientSecret); if (!changed) { - return; + return existing; } // update config on demand @@ -92,14 +92,14 @@ export class AuthProviderService { } else { const existing = await this.authProviderDB.findByHost(entry.host); if (existing) { - throw new Error("Provider for host has already been registered."); + throw new Error("Provider for this host already exists."); } authProvider = this.initializeNewProvider(entry); } - await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry); + return await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry); } protected initializeNewProvider(newEntry: AuthProviderEntry.NewEntry): AuthProviderEntry { - const { host, type } = newEntry; + const { host, type, clientId, clientSecret } = newEntry; const urls = type === "GitHub" ? githubUrls(host) : (type === "GitLab" ? gitlabUrls(host) : undefined); if (!urls) { throw new Error("Unexpected service type."); @@ -111,6 +111,8 @@ export class AuthProviderService { oauth: { ...urls, callBackUrl: this.callbackUrl(host), + clientId, + clientSecret, }, status: "pending", }; diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 6bc2523fa6df29..034c1fe09847d0 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -1498,13 +1498,7 @@ export class GitpodServerImpl { - const redacted = (entry: AuthProviderEntry) => ({ - ...entry, - oauth: { - ...entry.oauth, - clientSecret: "redacted" - } - }); + const redacted = (entry: AuthProviderEntry) => AuthProviderEntry.redact(entry); let userId: string; try { userId = this.checkAndBlockUser("getOwnAuthProviders").id; @@ -1531,7 +1525,7 @@ export class GitpodServerImpl { + async updateOwnAuthProvider({ entry }: GitpodServer.UpdateOwnAuthProviderParams): Promise { let userId: string; try { userId = this.checkAndBlockUser("updateOwnAuthProvider").id; @@ -1559,10 +1553,11 @@ export class GitpodServerImpl{ host: entry.host, type: entry.type, + clientId: entry.clientId, + clientSecret: entry.clientSecret, ownerId: entry.ownerId, } return safeEntry;