Skip to content

Commit 0d15bc6

Browse files
committed
Add New Project page and GH App installation
1 parent cf52446 commit 0d15bc6

File tree

15 files changed

+624
-37
lines changed

15 files changed

+624
-37
lines changed

components/dashboard/public/complete-auth/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<meta charset='utf-8'>
1111
<title>Done</title>
1212
<script>
13+
// @ts-check
1314
if (window.opener) {
1415
const search = new URLSearchParams(window.location.search);
1516
let message = search.get("message");

components/dashboard/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './st
3030
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
3131
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
3232
const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members'));
33+
const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/NewProject'));
3334
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
3435
const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './prebuilds/InstallGitHubApp'));
3536
const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer'));
@@ -147,6 +148,7 @@ function App() {
147148
<div className="container">
148149
<Menu />
149150
<Switch>
151+
<Route path="/new" exact component={NewProject} />
150152
<Route path="/setup" exact component={Setup} />
151153
<Route path="/workspaces" exact component={Workspaces} />
152154
<Route path="/account" exact component={Account} />
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useContext, useEffect, useState } from "react";
8+
import { getGitpodService, gitpodHostUrl } from "../service/service";
9+
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "../provider-utils";
10+
import { AuthProviderInfo, ProviderRepository, Team } from "@gitpod/gitpod-protocol";
11+
import { TeamsContext } from "../teams/teams-context";
12+
import { useLocation } from "react-router";
13+
14+
export default function NewProject() {
15+
16+
const { teams } = useContext(TeamsContext);
17+
18+
const [provider, setProvider] = useState<string>("github.com");
19+
const [reposInAccounts, setReposInAccounts] = useState<ProviderRepository[]>([]);
20+
const [selectedAccount, setSelectedAccount] = useState<string | undefined>(undefined);
21+
const [noOrgs, setNoOrgs] = useState<boolean>(false);
22+
const [showGitProviders, setShowGitProviders] = useState<boolean>(false);
23+
const [selectedRepo, setSelectedRepo] = useState<string | undefined>(undefined);
24+
const [selectedTeam, setSelectedTeam] = useState<Team | undefined>(undefined);
25+
const [authProviders, setAuthProviders] = useState<AuthProviderInfo[]>([]);
26+
27+
const location = useLocation();
28+
29+
useEffect(() => {
30+
const params = new URLSearchParams(location.search);
31+
const teamParam = params.get("team");
32+
if (teamParam) {
33+
window.history.replaceState({}, '', window.location.pathname);
34+
const team = teams?.find(t => t.slug === teamParam);
35+
setSelectedTeam(team);
36+
}
37+
38+
(async () => {
39+
updateOrgsState();
40+
const repos = await updateReposInAccounts();
41+
const first = repos[0];
42+
if (first) {
43+
setSelectedAccount(first.account);
44+
}
45+
setAuthProviders(await getGitpodService().server.getAuthProviders());
46+
})();
47+
}, []);
48+
49+
const isGitHub = () => provider === "github.com";
50+
51+
const updateReposInAccounts = async (installationId?: string) => {
52+
try {
53+
const repos = await getGitpodService().server.getProviderRepositoriesForUser({ provider, hints: { installationId } });
54+
setReposInAccounts(repos);
55+
return repos;
56+
} catch (error) {
57+
setReposInAccounts([]);
58+
console.log(error);
59+
}
60+
return [];
61+
}
62+
63+
const getToken = async (host: string) => {
64+
return getGitpodService().server.getToken({ host });
65+
}
66+
67+
const updateOrgsState = async () => {
68+
try {
69+
const ghToken = await getToken(provider);
70+
setNoOrgs(ghToken?.scopes.includes("read:org") !== true);
71+
} catch {
72+
}
73+
}
74+
75+
const reconfigure = () => {
76+
openReconfigureWindow({
77+
account: selectedAccount, onSuccess: (p: { installationId: string, setupAction?: string }) => {
78+
updateReposInAccounts(p.installationId);
79+
}
80+
});
81+
}
82+
83+
const grantReadOrgPermissions = async () => {
84+
try {
85+
await openAuthorizeWindow({
86+
host: "github.com",
87+
scopes: ["read:org"],
88+
onSuccess: () => {
89+
updateReposInAccounts();
90+
updateOrgsState();
91+
}
92+
})
93+
} catch (error) {
94+
console.log(error);
95+
}
96+
}
97+
98+
const selectRepo = (params: { name: string, account: string }) => {
99+
setSelectedRepo(params.name);
100+
101+
if (selectedTeam) {
102+
createProject(selectedTeam, params.name);
103+
}
104+
}
105+
106+
const createProject = async (team: Team, selectedRepo: string) => {
107+
const repo = reposInAccounts.find(r => r.account === selectedAccount && r.name === selectedRepo);
108+
if (!repo) {
109+
console.error("No repo selected!")
110+
return;
111+
}
112+
113+
await getGitpodService().server.createProject({
114+
name: repo.name,
115+
cloneUrl: repo.cloneUrl,
116+
account: repo.account,
117+
provider,
118+
appInstallationId: String(repo.installationId),
119+
teamId: team.id
120+
});
121+
// todo@alex clarify redirect target
122+
// window.location.href = `/${team.slug}/${repo.name}`;
123+
window.location.href = `/${team.slug}/projects`;
124+
}
125+
126+
const onSelectAccount = (value: string) => {
127+
if (value === "selectProvider") {
128+
setShowGitProviders(true);
129+
} else if (value === "addOrg") {
130+
reconfigure();
131+
} else {
132+
setSelectedAccount(value);
133+
}
134+
}
135+
136+
const toSimpleName = (fullName: string) => {
137+
const splitted = fullName.split("/");
138+
if (splitted.length < 2) {
139+
return fullName;
140+
}
141+
return splitted.shift() && splitted.join("/");
142+
}
143+
144+
const selectProvider = async (ap: AuthProviderInfo) => {
145+
const token = await getToken(ap.host);
146+
if (token) {
147+
setShowGitProviders(false);
148+
setProvider(ap.host);
149+
return;
150+
}
151+
await openAuthorizeWindow({
152+
host: ap.host,
153+
scopes: ap.requirements?.default,
154+
onSuccess: () => {
155+
setShowGitProviders(false);
156+
setProvider(ap.host);
157+
},
158+
onError: (error) => {
159+
console.log(error);
160+
}
161+
});
162+
}
163+
164+
const reposToRender = Array.from(reposInAccounts).filter(r => r.account === selectedAccount);
165+
const accounts = Array.from(new Set(Array.from(reposInAccounts).map(r => r.account)));
166+
167+
const renderSelectRepository = () => (<>
168+
<h3 className="pb-2 mt-8">Select Repository</h3>
169+
170+
<div className="mt-8 w-96 border rounded-xl border-gray-100 flex-col">
171+
{showGitProviders && (<>
172+
<div className="p-6">
173+
<div className="text-center text-gray-500">
174+
Select a Git provider first and continue with your repositories.
175+
</div>
176+
<div className="mt-6 flex flex-col space-y-3 items-center">
177+
{authProviders.map(ap => {
178+
return (
179+
<button key={"button" + ap.host} className="btn-login flex-none w-56 h-10 p-0 inline-flex" onClick={() => selectProvider(ap)}>
180+
{iconForAuthProvider(ap.authProviderType)}
181+
<span className="pt-2 pb-2 mr-3 text-sm my-auto font-medium truncate overflow-ellipsis">Continue with {simplifyProviderName(ap.host)}</span>
182+
</button>
183+
);
184+
})}
185+
</div>
186+
</div>
187+
</>)}
188+
{!showGitProviders && (<>
189+
<div className="p-6">
190+
<div className="flex flex-col space-y-2">
191+
<label htmlFor="selectAccount" className="font-medium">Account</label>
192+
<select name="selectAccount" value={selectedAccount} className="w-full"
193+
onChange={(e) => onSelectAccount(e.target.value)}>
194+
{accounts.map(a => (<option key={`account-${a}`} value={a}>{`${a} (${provider})`}</option>))}
195+
{isGitHub() && (<option value="addOrg">Add GH Org</option>)}
196+
<option value="selectProvider">Select Git Provider</option>
197+
</select>
198+
</div>
199+
<div className="flex-col mt-4">
200+
{reposToRender.length > 0 && (
201+
<div className="overscroll-contain max-h-96 overflow-y-auto">
202+
{reposToRender.map(r => (
203+
<div key={`repo-${r.name}`} className="rounded-md whitespace-nowrap flex space-x-2 py-2 px-3 w-full justify-between hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light transition ease-in-out group">
204+
<div className="w-8/12 text-gray-400 flex flex-col">
205+
<span className="text-gray-800 dark:text-gray-300 text-base font-semibold overflow-ellipsis truncate">{toSimpleName(r.name)}</span>
206+
<span className="">Updated __ ago</span>
207+
</div>
208+
<div className="w-4/12 flex justify-end">
209+
<div className="flex self-center hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer opacity-0 group-hover:opacity-100">
210+
<button className="primary py-1" onClick={() => selectRepo(r)}>Select</button>
211+
</div>
212+
</div>
213+
</div>
214+
))}
215+
</div>
216+
)}
217+
</div>
218+
</div>
219+
<div className="p-3 bg-gray-100">
220+
<div className="text-gray-500 text-center">
221+
Repository not found? <a href="javascript:void(0)" onClick={e => reconfigure()} className="text-gray-400 underline underline-thickness-thin underline-offset-small hover:text-gray-600">Reconfigure</a>
222+
</div>
223+
{noOrgs && (
224+
<div className="text-gray-500 mx-auto text-center">
225+
Missing organizations? <a href="javascript:void(0)" onClick={e => grantReadOrgPermissions()} className="text-gray-400 underline underline-thickness-thin underline-offset-small hover:text-gray-600">Grant permissions</a>
226+
</div>
227+
)}
228+
</div>
229+
</>)}
230+
</div>
231+
</>);
232+
233+
const renderSelectTeam = () => (<>
234+
<h3 className="pb-2 mt-8">Select Team</h3>
235+
<h4 className="pb-2">Adding <strong>{selectedRepo}</strong></h4>
236+
237+
<div className="mt-8 w-96 border rounded-xl border-gray-100 flex-col">
238+
{(teams || []).map((t) => (
239+
<div key={`team-${t.name}`} className={`border-b p-6 flex space-x-2 w-full justify-between dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light transition ease-in-out group`}>
240+
<div className="w-8/12 m-auto overflow-ellipsis truncate">{t.name}</div>
241+
<div className="w-4/12 flex justify-end">
242+
<div className="flex self-center hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer opacity-0 group-hover:opacity-100">
243+
<button className="primary py-1" onClick={() => createProject(t, selectedRepo!)}>Select</button>
244+
</div>
245+
</div>
246+
</div>
247+
))}
248+
<div className="p-6 flex text-gray-500">
249+
New Team
250+
</div>
251+
</div>
252+
</>);
253+
254+
return (<div className="flex flex-col w-96 mt-16 mx-auto items-center">
255+
<h1>New Project</h1>
256+
<p className="text-gray-500 text-center text-base">Project allow you to set up and acess Prebuilds.</p>
257+
258+
{!selectedRepo && renderSelectRepository()}
259+
260+
{selectedRepo && renderSelectTeam()}
261+
262+
</div>);
263+
264+
}
265+
266+
async function openReconfigureWindow(params: { account?: string, onSuccess: (p: any) => void }) {
267+
const { account, onSuccess } = params;
268+
const state = btoa(JSON.stringify({ from: "/reconfigure", next: "/new" }));
269+
const url = gitpodHostUrl.withApi({
270+
pathname: '/apps/github/reconfigure',
271+
search: `account=${account}&state=${encodeURIComponent(state)}`
272+
}).toString();
273+
274+
// Optimistically assume that the new window was opened.
275+
window.open(url, "gitpod-github-window", "width=800,height=800,status=yes,scrollbars=yes,resizable=yes");
276+
277+
const eventListener = (event: MessageEvent) => {
278+
// todo: check event.origin
279+
280+
const killWindow = () => {
281+
window.removeEventListener("message", eventListener);
282+
283+
if (event.source && "close" in event.source && event.source.close) {
284+
console.log(`Received Window Result. Closing Window.`);
285+
event.source.close();
286+
}
287+
}
288+
289+
if (typeof event.data === "string" && event.data.startsWith("payload:")) {
290+
killWindow();
291+
try {
292+
let payload: { installationId: string, setupAction?: string } = JSON.parse(atob(event.data.substring("payload:".length)));
293+
onSuccess && onSuccess(payload);
294+
} catch (error) {
295+
console.log(error);
296+
}
297+
}
298+
if (typeof event.data === "string" && event.data.startsWith("error:")) {
299+
let error: string | { error: string, description?: string } = atob(event.data.substring("error:".length));
300+
try {
301+
const payload = JSON.parse(error);
302+
if (typeof payload === "object" && payload.error) {
303+
error = { error: payload.error, description: payload.description };
304+
}
305+
} catch (error) {
306+
console.log(error);
307+
}
308+
309+
killWindow();
310+
// onError && onError(error);
311+
}
312+
};
313+
window.addEventListener("message", eventListener);
314+
}

0 commit comments

Comments
 (0)