Skip to content

Add Select Account Modal #3897

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@
<html>
<head>
<meta charset='utf-8'>
<title>Login successful</title>
<title>Done</title>
<script>
window.opener.postMessage("auth-success", `https://${window.location.hostname}`);
if (window.opener) {
const message = new URLSearchParams(window.location.search).get("message");
window.opener.postMessage(message, `https://${window.location.hostname}`);
} else {
console.log("This page is supposed to be opened by Gitpod.")
}
</script>
</head>
<body>
Expand Down
7 changes: 3 additions & 4 deletions components/dashboard/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,12 @@ export function Login() {
const listener = (event: MessageEvent<any>) => {
// todo: check event.origin

if (event.data === "auth-success") {
if (event.data === "success") {
if (event.source && "close" in event.source && event.source.close) {
console.log(`try to close window`);
event.source.close();
} else {
// todo: not here, but add a button to the /login-success page to close, if this should not work as expected
}

(async () => {
await getGitpodService().reconnect();
setUser(await getGitpodService().server.getLoggedInUser());
Expand Down Expand Up @@ -139,7 +138,7 @@ export function Login() {
}

function getLoginUrl(host: string) {
const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString();
const returnTo = gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString();
return gitpodHostUrl.withApi({
pathname: '/login',
search: `host=${host}&returnTo=${encodeURIComponent(returnTo)}`
Expand Down
26 changes: 12 additions & 14 deletions components/dashboard/src/prebuilds/InstallGitHubApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,33 @@ import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
import { getGitpodService, gitpodHostUrl } from "../service/service";
import { useState } from "react";
import info from "../images/info.svg";
import { openAuthorizeWindow } from "../provider-utils";

async function registerApp(installationId: string, setModal: (modal: 'done' | string | undefined) => void) {
try {
await getGitpodService().server.registerGithubApp(installationId);

const returnTo = encodeURIComponent(gitpodHostUrl.with({ pathname: `login-success` }).toString());
const returnTo = encodeURIComponent(gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString());
const url = gitpodHostUrl.withApi({
pathname: '/authorize',
search: `returnTo=${returnTo}&host=github.com&scopes=repo`
}).toString();
window.open(url, "gitpod-login");

const result = new Deferred<void>(1000 * 60 * 10 /* 10 min */);
result.promise.catch(e => setModal('error'));
const listener = (event: MessageEvent<any>) => {
// todo: check event.origin
if (event.data === "auth-success") {
if (event.source && "close" in event.source && event.source.close) {
console.log(`try to close window`);
event.source.close();
} else {
// todo: not here, but add a button to the /login-success page to close, if this should not work as expected
}
window.removeEventListener("message", listener);

openAuthorizeWindow({
host: "github.com",
scopes: ["repo"],
onSuccess: () => {
setModal('done');
result.resolve();
},
onError: (error) => {
setModal(error);
}
};
window.addEventListener("message", listener);
})

return result.promise;
} catch (e) {
setModal(e.message);
Expand Down
23 changes: 15 additions & 8 deletions components/dashboard/src/provider-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ function simplifyProviderName(host: string) {
}
}

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();
async function openAuthorizeWindow({ host, scopes, onSuccess, onError }: { host: string, scopes?: string[], onSuccess?: (payload?: string) => void, onError?: (error?: string) => void }) {
const returnTo = gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=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");
const newWindow = window.open(url, "gitpod-auth-window");
if (!newWindow) {
console.log(`Failed to open the authorize window for ${host}`);
onError && onError("failed");
Expand All @@ -53,17 +53,24 @@ async function openAuthorizeWindow({ host, scopes, onSuccess, onError }: { host:
const eventListener = (event: MessageEvent) => {
// todo: check event.origin

if (event.data === "auth-success") {
const killAuthWindow = () => {
window.removeEventListener("message", eventListener);

if (event.source && "close" in event.source && event.source.close) {
console.log(`Authorization OK. Closing child window.`);
console.log(`Received Auth Window Result. Closing Window.`);
event.source.close();
} else {
// todo: add a button to the /login-success page to close, if this should not work as expected
}
}

if (typeof event.data === "string" && event.data.startsWith("success")) {
killAuthWindow();
onSuccess && onSuccess();
}
if (typeof event.data === "string" && event.data.startsWith("error:")) {
const errorText = atob(event.data.substring("error:".length));
killAuthWindow();
onError && onError(errorText);
}
};
window.addEventListener("message", eventListener);
}
Expand Down
29 changes: 26 additions & 3 deletions components/dashboard/src/settings/Integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { AuthProviderEntry, AuthProviderInfo } from "@gitpod/gitpod-protocol";
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
import React, { useContext, useEffect, useState } from "react";
import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu";
import { getGitpodService, gitpodHostUrl } from "../service/service";
Expand All @@ -17,6 +18,7 @@ import { openAuthorizeWindow } from "../provider-utils";
import CheckBox from '../components/CheckBox';
import { PageWithSubMenu } from "../components/PageWithSubMenu";
import settingsMenu from "./settings-menu";
import { SelectAccountModal } from "./SelectAccountModal";

export default function Integrations() {

Expand All @@ -38,6 +40,7 @@ function GitProviders() {
const [allScopes, setAllScopes] = useState<Map<string, string[]>>(new Map());
const [diconnectModal, setDisconnectModal] = useState<{ provider: AuthProviderInfo } | undefined>(undefined);
const [editModal, setEditModal] = useState<{ provider: AuthProviderInfo, prevScopes: Set<string>, nextScopes: Set<string> } | undefined>(undefined);
const [selectAccountModal, setSelectAccountModal] = useState<SelectAccountPayload | undefined>(undefined);

useEffect(() => {
updateAuthProviders();
Expand Down Expand Up @@ -120,7 +123,7 @@ function GitProviders() {

const disconnect = async (ap: AuthProviderInfo) => {
setDisconnectModal(undefined);
const returnTo = gitpodHostUrl.with({ pathname: 'login-success' }).toString();
const returnTo = gitpodHostUrl.with({ pathname: 'flow-result', search: 'message=success' }).toString();
const deauthorizeUrl = gitpodHostUrl.withApi({
pathname: '/deauthorize',
search: `returnTo=${returnTo}&host=${ap.host}`
Expand Down Expand Up @@ -152,7 +155,23 @@ function GitProviders() {

const doAuthorize = async (host: string, scopes?: string[]) => {
try {
await openAuthorizeWindow({ host, scopes, onSuccess: () => updateUser() });
await openAuthorizeWindow({
host,
scopes,
onSuccess: () => updateUser(),
onError: (error) => {
if (typeof error === "string") {
try {
const payload = JSON.parse(error);
if (SelectAccountPayload.is(payload)) {
setSelectAccountModal(payload)
}
} catch (error) {
console.log(error);
}
}
}
});
} catch (error) {
console.log(error)
}
Expand Down Expand Up @@ -208,6 +227,10 @@ function GitProviders() {

return (<div>

{selectAccountModal && (
<SelectAccountModal {...selectAccountModal} close={() => setSelectAccountModal(undefined)} />
)}

{diconnectModal && (
<Modal visible={true} onClose={() => setDisconnectModal(undefined)}>
<h3 className="pb-2">Disconnect Provider</h3>
Expand Down Expand Up @@ -627,4 +650,4 @@ function GitIntegrationModal(props: ({

function equals(a: Set<string>, b: Set<string>): boolean {
return a.size === b.size && Array.from(a).every(e => b.has(e));
}
}
86 changes: 86 additions & 0 deletions components/dashboard/src/settings/SelectAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* 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 { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
import { useEffect, useState } from "react";
import { gitpodHostUrl } from "../service/service";
import Modal from "../components/Modal";
import SelectableCard from "../components/SelectableCard";
import info from '../images/info.svg';

export function SelectAccountModal(props: SelectAccountPayload & {
close: () => void
}) {

const [useAccount, setUseAccount] = useState<"current" | "other">("current");

useEffect(() => {
}, []);

const continueWithCurrentAccount = () => {
props.close();
};

const continueWithOtherAccount = () => {
const accessControlUrl = gitpodHostUrl.asAccessControl().toString();

const loginUrl = gitpodHostUrl.withApi({
pathname: '/login',
search: `host=${props.otherUser.authHost}&returnTo=${encodeURIComponent(accessControlUrl)}`
}).toString();

const logoutUrl = gitpodHostUrl.withApi({
pathname: "/logout",
search: `returnTo=${encodeURIComponent(loginUrl)}`
}).toString();

window.location.href = logoutUrl;
};

return (<Modal visible={true} onClose={props.close}>
<h3 className="pb-2">Select Account</h3>
<div className="border-t border-b border-gray-200 mt-2 -mx-6 px-6 py-4">
<p className="pb-2 text-gray-500 text-base">You are trying to authorize a provider that is already connected with another account on Gitpod.</p>

<div className="mt-4 flex rounded-md w-full bg-gray-200 p-4 mx-auto">
<img className="w-4 h-4 m-1 ml-2 mr-4" src={info} />
<span>
Disconnect a provider in one of you accounts, if you like to continue with the other account.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: WDYT of the following? Alternatively, we could split the following into two alert components, one for information (gray) and one for warning (red).

Suggested change
Disconnect a provider in one of you accounts, if you like to continue with the other account.
This will disconnect the non-selected account and delete all its associated workspaces data and user settings like environment variables. To continue using the other account, go user settings and disconnect the existing provider connection.

</span>
</div>

<div className="mt-10 mb-6 flex-grow flex flex-row justify-around align-center">
<SelectableCard className="w-2/5 h-56" title="Current Account" selected={useAccount === 'current'} onClick={() => setUseAccount('current')}>
<div className="flex-grow flex flex-col justify-center align-center">
<img className="m-auto rounded-full w-24 h-24 py-4" src={props.currentUser.avatarUrl} alt={props.currentUser.name}/>
<span className="m-auto text-gray-700 text-md font-semibold">{props.currentUser.authName}</span>
<span className="m-auto text-gray-400 text-md">{props.currentUser.authHost}</span>
</div>
</SelectableCard>

<SelectableCard className="w-2/5 h-56" title="Other Account" selected={useAccount === 'other'} onClick={() => setUseAccount('other')}>
<div className="flex-grow flex flex-col justify-center align-center">
<img className="m-auto rounded-full w-24 h-24 py-4" src={props.otherUser.avatarUrl} alt={props.otherUser.name}/>
<span className="m-auto text-gray-700 text-md font-semibold">{props.otherUser.authName}</span>
<span className="m-auto text-gray-400 text-md">{props.otherUser.authHost}</span>
</div>
</SelectableCard>
</div>


</div>

<div className="flex justify-end mt-6">
<button className={"ml-2"} onClick={() => {
if (useAccount === "other") {
continueWithOtherAccount();
} else {
continueWithCurrentAccount();
}
}}>Continue</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Shall we also offer a secondary option to go back to user settings, Integrations?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. in case we're seeing this on the /integrations page, the continue with current basically means stay here ✅
  2. in case we're seeing this on creation of workspaces, then it's translated to visiting /integrations ✅

please verify

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a good MVC. Let's do it.

</div>
</Modal>);
}
50 changes: 43 additions & 7 deletions components/dashboard/src/start/CreateWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { getGitpodService, gitpodHostUrl } from "../service/service";
import { UserContext } from "../user-context";
import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";
import StartWorkspace from "./StartWorkspace";
import { openAuthorizeWindow } from "../provider-utils";
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
import { SelectAccountModal } from "../settings/SelectAccountModal";

const WorkspaceLogs = React.lazy(() => import('./WorkspaceLogs'));

Expand All @@ -23,6 +26,7 @@ export interface CreateWorkspaceProps {
export interface CreateWorkspaceState {
result?: WorkspaceCreationResult;
error?: StartWorkspaceError;
selectAccountError?: SelectAccountPayload;
stillParsing: boolean;
}

Expand Down Expand Up @@ -62,7 +66,43 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
}
}

async tryAuthorize(host: string, scopes?: string[]) {
try {
await openAuthorizeWindow({
host,
scopes,
onSuccess: () => {
window.location.href = window.location.toString();
},
onError: (error) => {
if (typeof error === "string") {
try {
const payload = JSON.parse(error);
if (SelectAccountPayload.is(payload)) {
this.setState({ selectAccountError: payload });
}
} catch (error) {
console.log(error);
}
}
}
});
} catch (error) {
console.log(error)
}
};

render() {
if (SelectAccountPayload.is(this.state.selectAccountError)) {
return (<StartPage phase={StartPhase.Checking}>
<div className="mt-2 flex flex-col space-y-8">
<SelectAccountModal {...this.state.selectAccountError} close={() => {
window.location.href = gitpodHostUrl.asAccessControl().toString();
}} />
</div>
</StartPage>);
}

let phase = StartPhase.Checking;
let statusMessage = <p className="text-base text-gray-400">{this.state.stillParsing ? 'Parsing context …' : 'Preparing workspace …'}</p>;

Expand All @@ -75,14 +115,10 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
</div>;
break;
case ErrorCodes.NOT_AUTHENTICATED:
const authorizeUrl = gitpodHostUrl.withApi({
pathname: '/authorize',
search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${error.data.host}&scopes=${error.data.scopes.join(',')}`
}).toString();
window.location.href = authorizeUrl;
statusMessage = <div className="mt-2 flex flex-col space-y-8">
<p className="text-base w-96">Redirecting to authorize with {error.data.host} …</p>
<a href={authorizeUrl}><button className="secondary">Authorize with {error.data.host}</button></a>
<button className="secondary" onClick={() => {
this.tryAuthorize(error?.data.host, error?.data.scopes)
}}>Authorize with {error.data.host}</button>
</div>;
break;
case ErrorCodes.USER_BLOCKED:
Expand Down
4 changes: 3 additions & 1 deletion components/server/src/auth/generic-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ export class GenericAuthProvider implements AuthProvider {

if (SelectAccountException.is(err)) {
this.selectAccountCookie.set(response, err.payload);
const url = this.env.hostUrl.with({ pathname: '/select-account' }).toString();

// option 1: send as GET param on redirect
const url = this.env.hostUrl.with({ pathname: '/flow-result', search: "message=error:" + Buffer.from(JSON.stringify(err.payload), "utf-8").toString('base64') }).toString();
response.redirect(url);
return;
}
Expand Down