diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 14564b3a511632..80af1b8c2ed80f 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -17,7 +17,9 @@ import settingsMenu from './settings/settings-menu'; import { User } from '@gitpod/gitpod-protocol'; import { adminMenu } from './admin/admin-menu'; import gitpodIcon from './icons/gitpod.svg'; +import { ErrorCodes } from '@gitpod/gitpod-protocol/lib/messaging/error'; +const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup')); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces')); const Account = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Account')); const Notifications = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Notifications')); @@ -43,6 +45,7 @@ function App() { const [loading, setLoading] = useState(true); const [isWhatsNewShown, setWhatsNewShown] = useState(false); + const [isSetupRequired, setSetupRequired] = useState(false); useEffect(() => { (async () => { @@ -51,6 +54,11 @@ function App() { setUser(usr); } catch (error) { console.log(error); + if (error && "code" in error) { + if (error.code === ErrorCodes.SETUP_REQUIRED) { + setSetupRequired(true); + } + } } setLoading(false); })(); @@ -88,10 +96,15 @@ function App() { } if (loading) { - return + return (); + } + if (isSetupRequired) { + return (}> + + ); } if (!user) { - return () + return (); } if (window.location.pathname.startsWith('/blocked')) { return
@@ -117,6 +130,7 @@ function App() {
{renderMenu(user)} + diff --git a/components/dashboard/src/Login.tsx b/components/dashboard/src/Login.tsx index 0f75ba0b121caa..a8583bcdb697da 100644 --- a/components/dashboard/src/Login.tsx +++ b/components/dashboard/src/Login.tsx @@ -58,21 +58,17 @@ export function Login() { login: true, host, onSuccess: () => updateUser(), - onError: (error) => { - if (typeof error === "string") { - try { - const payload = JSON.parse(error); - if (typeof payload === "object" && payload.error) { - if (payload.error === "email_taken") { - return setErrorMessage(`Email address already exists. Log in using a different provider.`); - } - return setErrorMessage(payload.description ? payload.description : `Error: ${payload.error}`); - } - } catch (error) { - console.log(error); + onError: (payload) => { + let errorMessage: string; + if (typeof payload === "string") { + errorMessage = payload; + } else { + errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; + if (payload.error === "email_taken") { + errorMessage = `Email address already exists. Log in using a different provider.`; } - setErrorMessage(error); } + setErrorMessage(errorMessage); } }); } catch (error) { diff --git a/components/dashboard/src/Setup.tsx b/components/dashboard/src/Setup.tsx new file mode 100644 index 00000000000000..13a8ec92dab827 --- /dev/null +++ b/components/dashboard/src/Setup.tsx @@ -0,0 +1,61 @@ +/** + * 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. + */ + +import { useEffect, useState } from "react"; +import Modal from "./components/Modal"; +import { getGitpodService, gitpodHostUrl } from "./service/service"; +import { GitIntegrationModal } from "./settings/Integrations"; + +export default function Setup() { + + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + (async () => { + const dynamicAuthProviders = await getGitpodService().server.getOwnAuthProviders(); + const previous = dynamicAuthProviders.filter(ap => ap.ownerId === "no-user")[0]; + if (previous) { + await getGitpodService().server.deleteOwnAuthProvider({ id: previous.id }); + } + })(); + }, []); + + const acceptAndContinue = () => { + setShowModal(true); + } + + const onAuthorize = (payload?: string) => { + // run without await, so the integrated closing of new tab isn't blocked + (async () => { + window.location.href = gitpodHostUrl.asDashboard().toString(); + })(); + } + + const headerText = "Configure a git integration with a GitLab or GitHub instance." + + return
+ {!showModal && ( + { }} closeable={false}> +

Welcome to Gitpod 🎉

+
+

To start using Gitpod, you will need to set up a git integration.

+ +
+ + By using Gitpod, you agree to our terms. + +
+
+
+ +
+
+ )} + {showModal && ( + + )} +
; +} diff --git a/components/dashboard/src/prebuilds/InstallGitHubApp.tsx b/components/dashboard/src/prebuilds/InstallGitHubApp.tsx index d1091b51924551..11afb5aade12c9 100644 --- a/components/dashboard/src/prebuilds/InstallGitHubApp.tsx +++ b/components/dashboard/src/prebuilds/InstallGitHubApp.tsx @@ -25,8 +25,14 @@ async function registerApp(installationId: string, setModal: (modal: 'done' | st setModal('done'); result.resolve(); }, - onError: (error) => { - setModal(error); + onError: (payload) => { + let errorMessage: string; + if (typeof payload === "string") { + errorMessage = payload; + } else { + errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; + } + setModal(errorMessage); } }) @@ -46,7 +52,7 @@ export default function InstallGitHubApp() {

No Installation ID Found

-
Did you came here from the GitHub app's page?
+
Did you come here from the GitHub app's page?
diff --git a/components/dashboard/src/provider-utils.tsx b/components/dashboard/src/provider-utils.tsx index 324d28221e573c..80fda0bb859b4e 100644 --- a/components/dashboard/src/provider-utils.tsx +++ b/components/dashboard/src/provider-utils.tsx @@ -41,7 +41,7 @@ interface OpenAuthorizeWindowParams { scopes?: string[]; overrideScopes?: boolean; onSuccess?: (payload?: string) => void; - onError?: (error?: string) => void; + onError?: (error: string | { error: string, description?: string }) => void; } async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) { @@ -75,12 +75,21 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) { if (typeof event.data === "string" && event.data.startsWith("success")) { killAuthWindow(); - onSuccess && onSuccess(); + onSuccess && onSuccess(event.data); } if (typeof event.data === "string" && event.data.startsWith("error:")) { - const errorAsText = atob(event.data.substring("error:".length)); + let error: string | { error: string, description?: string } = atob(event.data.substring("error:".length)); + try { + const payload = JSON.parse(error); + if (typeof payload === "object" && payload.error) { + error = { error: payload.error, description: payload.description }; + } + } catch (error) { + console.log(error); + } + killAuthWindow(); - onError && onError(errorAsText); + onError && onError(error); } }; window.addEventListener("message", eventListener); diff --git a/components/dashboard/src/settings/Integrations.tsx b/components/dashboard/src/settings/Integrations.tsx index 416e82797f7bd3..f4fe2be3f9c5e2 100644 --- a/components/dashboard/src/settings/Integrations.tsx +++ b/components/dashboard/src/settings/Integrations.tsx @@ -38,7 +38,7 @@ function GitProviders() { const [authProviders, setAuthProviders] = useState([]); const [allScopes, setAllScopes] = useState>(new Map()); - const [diconnectModal, setDisconnectModal] = useState<{ provider: AuthProviderInfo } | undefined>(undefined); + const [disconnectModal, setDisconnectModal] = useState<{ provider: AuthProviderInfo } | undefined>(undefined); const [editModal, setEditModal] = useState<{ provider: AuthProviderInfo, prevScopes: Set, nextScopes: Set } | undefined>(undefined); const [selectAccountModal, setSelectAccountModal] = useState(undefined); @@ -232,18 +232,18 @@ function GitProviders() { setSelectAccountModal(undefined)} /> )} - {diconnectModal && ( + {disconnectModal && ( setDisconnectModal(undefined)}>

Disconnect Provider

Are you sure you want to disconnect the following provider?

-
{diconnectModal.provider.authProviderType}
-
{diconnectModal.provider.host}
+
{disconnectModal.provider.authProviderType}
+
{disconnectModal.provider.host}
- +
)} @@ -358,10 +358,10 @@ function GitIntegrations() { return (
{modal?.mode === "new" && ( - setModal(undefined)} update={updateOwnAuthProviders} /> + setModal(undefined)} onUpdate={updateOwnAuthProviders} /> )} {modal?.mode === "edit" && ( - setModal(undefined)} update={updateOwnAuthProviders} /> + setModal(undefined)} onUpdate={updateOwnAuthProviders} /> )} {modal?.mode === "delete" && ( setModal(undefined)}> @@ -397,7 +397,7 @@ function GitIntegrations() {

No Git Integrations

-
In addition to the default Git Providers you can authorize
with a self hosted instace of a provider.
+
In addition to the default Git Providers you can authorize
with a self-hosted instace of a provider.
@@ -430,15 +430,19 @@ function GitIntegrations() {
); } -function GitIntegrationModal(props: ({ +export function GitIntegrationModal(props: ({ mode: "new", } | { mode: "edit", provider: AuthProviderEntry }) & { + login?: boolean, + headerText?: string, userId: string, - onClose?: () => void - update?: () => void + onClose?: () => void, + closeable?: boolean, + onUpdate?: () => void, + onAuthorize?: (payload?: string) => void }) { const callbackUrl = (host: string) => { @@ -446,6 +450,9 @@ function GitIntegrationModal(props: ({ return gitpodHostUrl.with({ pathname }).toString(); } + const [mode, setMode] = useState<"new" | "edit">("new"); + const [providerEntry, setProviderEntry] = useState(undefined); + const [type, setType] = useState("GitLab"); const [host, setHost] = useState("gitlab.example.com"); const [redirectURL, setRedirectURL] = useState(callbackUrl("gitlab.example.com")); @@ -456,7 +463,9 @@ function GitIntegrationModal(props: ({ const [validationError, setValidationError] = useState(); useEffect(() => { + setMode(props.mode); if (props.mode === "edit") { + setProviderEntry(props.provider); setType(props.provider.type); setHost(props.provider.host); setClientId(props.provider.oauth.clientId); @@ -466,21 +475,22 @@ function GitIntegrationModal(props: ({ }, []); useEffect(() => { + setErrorMessage(undefined); validate(); }, [clientId, clientSecret]) - const close = () => props.onClose && props.onClose(); - const updateList = () => props.update && props.update(); + const onClose = () => props.onClose && props.onClose(); + const onUpdate = () => props.onUpdate && props.onUpdate(); const activate = async () => { - let entry = (props.mode === "new") ? { + let entry = (mode === "new") ? { host, type, clientId, clientSecret, ownerId: props.userId } as AuthProviderEntry.NewEntry : { - id: props.provider.id, + id: providerEntry?.id, ownerId: props.userId, clientId, clientSecret: clientSecret === "redacted" ? undefined : clientSecret @@ -495,13 +505,45 @@ function GitIntegrationModal(props: ({ // 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(); + onUpdate(); + + const updateProviderEntry = async () => { + const provider = (await getGitpodService().server.getOwnAuthProviders()).find(ap => ap.id === newProvider.id); + if (provider) { + setProviderEntry(provider); + } + } // just open the authorization window and do *not* await - openAuthorizeWindow({ host: newProvider.host, onSuccess: updateList }); + openAuthorizeWindow({ + login: props.login, + host: newProvider.host, + onSuccess: (payload) => { + updateProviderEntry(); + onUpdate(); + props.onAuthorize && props.onAuthorize(payload); + }, + onError: (payload) => { + updateProviderEntry(); + let errorMessage: string; + if (typeof payload === "string") { + errorMessage = payload; + } else { + errorMessage = payload.description ? payload.description : `Error: ${payload.error}`; + } + setErrorMessage(errorMessage); + } + }); - // close the modal, as the creation phase is done anyways. - close(); + if (props.closeable) { + // close the modal, as the creation phase is done anyways. + onClose(); + } else { + // switch mode to stay and edit this integration. + // this modal is expected to be closed programmatically. + setMode("edit"); + setProviderEntry(newProvider); + } } catch (error) { console.log(error); setErrorMessage("message" in error ? error.message : "Failed to update Git provider"); @@ -510,7 +552,7 @@ function GitIntegrationModal(props: ({ } const updateHostValue = (host: string) => { - if (props.mode === "new") { + if (mode === "new") { setHost(host); setRedirectURL(callbackUrl(host)); setErrorMessage(undefined); @@ -582,60 +624,58 @@ function GitIntegrationModal(props: ({ } }; - return ( -

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

+ return ( +

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

- {props.mode === "edit" && props.provider.status === "pending" && ( + {mode === "edit" && providerEntry?.status !== "verified" && ( You need to activate this integration. )}
- Configure a git integration with a GitLab or GitHub self-hosted instance. + {props.headerText || "Configure a git integration with a GitLab or GitHub self-hosted instance."}
- {props.mode === "new" && ( + +
+ {mode === "new" && ( +
+ + +
+ )}
- - + + updateHostValue(e.target.value)} />
- )} -
- - updateHostValue(e.target.value)} /> -
-
- -
- -
copyRedirectUrl()}> - +
+ +
+ +
copyRedirectUrl()}> + +
+ {getRedirectUrlDescription(type, host)}
- {getRedirectUrlDescription(type, host)} -
-
- - updateClientId(e.target.value)} /> -
-
- - updateClientSecret(e.target.value)} /> -
- {errorMessage && ( -
- - {errorMessage} +
+ + updateClientId(e.target.value)} />
- )} - {!!validationError && ( +
+ + updateClientSecret(e.target.value)} /> +
+
+ + {(errorMessage || validationError) && (
- {validationError} + {errorMessage || validationError}
)}
diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 495f16999830ce..265a56febcd3c1 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -186,7 +186,7 @@ export class GitpodServerImpl { - const hasAnyStaticProviders = this.hostContextProvider.getAll().some(hc => hc.authProvider.config.builtin === true); - if (!hasAnyStaticProviders) { - const userCount = await this.userDB.getUserCount(); - this.setupRequired = userCount === 0; + + // execute the check for the setup to be shown until the setup is not required. + // cf. evaluation of the condition in `checkUser` + if (!this.showSetupCondition || this.showSetupCondition.value === true) { + const hasAnyStaticProviders = this.hostContextProvider.getAll().some(hc => hc.authProvider.config.builtin === true); + if (!hasAnyStaticProviders) { + const userCount = await this.userDB.getUserCount(); + this.showSetupCondition = { value: userCount === 0 }; + } else { + this.showSetupCondition = { value: false }; + } } if (this.user) { @@ -276,6 +283,9 @@ export class GitpodServerImpl hc.authProvider.info); + const isNotHidden = (info: AuthProviderInfo) => !info.hiddenOnDashboard; + const isVerified = (info: AuthProviderInfo) => info.verified; + // if no user session is available, compute public information only if (!this.user) { const toPublic = (info: AuthProviderInfo) => { @@ -286,7 +296,7 @@ export class GitpodServerImpl