Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/workspaces/CreateWorkspacePage.tsx
2500 views
1
/**
2
* Copyright (c) 2023 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 { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
8
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
9
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
10
import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
11
import { FC, FunctionComponent, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from "react";
12
import { useHistory, useLocation } from "react-router";
13
import Alert from "../components/Alert";
14
import { AuthorizeGit, useNeedsGitAuthorization } from "../components/AuthorizeGit";
15
import { LinkButton } from "../components/LinkButton";
16
import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";
17
import RepositoryFinder from "../components/RepositoryFinder";
18
import SelectIDEComponent from "../components/SelectIDEComponent";
19
import SelectWorkspaceClassComponent from "../components/SelectWorkspaceClassComponent";
20
import { UsageLimitReachedModal } from "../components/UsageLimitReachedModal";
21
import { InputField } from "../components/forms/InputField";
22
import { Heading1 } from "../components/typography/headings";
23
import { useAuthProviderDescriptions } from "../data/auth-providers/auth-provider-descriptions-query";
24
import { useCurrentOrg } from "../data/organizations/orgs-query";
25
import { useCreateWorkspaceMutation } from "../data/workspaces/create-workspace-mutation";
26
import { useListWorkspacesQuery } from "../data/workspaces/list-workspaces-query";
27
import { useWorkspaceContext } from "../data/workspaces/resolve-context-query";
28
import { useDirtyState } from "../hooks/use-dirty-state";
29
import { openAuthorizeWindow } from "../provider-utils";
30
import { gitpodHostUrl } from "../service/service";
31
import { StartPage, StartWorkspaceError } from "../start/StartPage";
32
import { VerifyModal } from "../start/VerifyModal";
33
import { StartWorkspaceOptions } from "../start/start-workspace-options";
34
import { UserContext, useCurrentUser } from "../user-context";
35
import { SelectAccountModal } from "../user-settings/SelectAccountModal";
36
import { settingsPathIntegrations } from "../user-settings/settings.routes";
37
import { BrowserExtensionBanner } from "./BrowserExtensionBanner";
38
import { WorkspaceEntry } from "./WorkspaceEntry";
39
import { AuthProviderType } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
40
import {
41
CreateAndStartWorkspaceRequest_ContextURL,
42
WorkspacePhase_Phase,
43
} from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
44
import { Button } from "@podkit/buttons/Button";
45
import { LoadingButton } from "@podkit/buttons/LoadingButton";
46
import { CreateAndStartWorkspaceRequest } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
47
import { PartialMessage } from "@bufbuild/protobuf";
48
import { User_WorkspaceAutostartOption } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
49
import { EditorReference } from "@gitpod/public-api/lib/gitpod/v1/editor_pb";
50
import { converter } from "../service/public-api";
51
import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
52
import { useAllowedWorkspaceClassesMemo } from "../data/workspaces/workspace-classes-query";
53
import Menu from "../menu/Menu";
54
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";
55
import { useAllowedWorkspaceEditorsMemo } from "../data/ide-options/ide-options-query";
56
import { isGitpodIo } from "../utils";
57
import { useListConfigurations } from "../data/configurations/configuration-queries";
58
import { flattenPagedConfigurations } from "../data/git-providers/unified-repositories-search-query";
59
import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
60
import { useMemberRole } from "../data/organizations/members-query";
61
import { OrganizationPermission } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
62
import { useInstallationConfiguration } from "../data/installation/installation-config-query";
63
import { MaintenanceModeBanner } from "../org-admin/MaintenanceModeBanner";
64
65
type NextLoadOption = "searchParams" | "autoStart" | "allDone";
66
67
export const StartWorkspaceKeyBinding = `${/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl﹢"}Enter`;
68
69
export function CreateWorkspacePage() {
70
const { user, setUser } = useContext(UserContext);
71
const updateUser = useUpdateCurrentUserMutation();
72
const currentOrg = useCurrentOrg().data;
73
const workspaces = useListWorkspacesQuery({ limit: 50 });
74
const location = useLocation();
75
const history = useHistory();
76
const props = StartWorkspaceOptions.parseSearchParams(location.search);
77
const [autostart, setAutostart] = useState<boolean | undefined>(props.autostart);
78
const createWorkspaceMutation = useCreateWorkspaceMutation();
79
80
// Currently this tracks if the user has selected a project from the dropdown
81
// Need to make sure we initialize this to a project if the url hash value maps to a project's repo url
82
// Will need to handle multiple projects w/ same repo url
83
const [selectedProjectID, setSelectedProjectID] = useState<string | undefined>(undefined);
84
85
const defaultLatestIde =
86
props.ideSettings?.useLatestVersion !== undefined
87
? props.ideSettings.useLatestVersion
88
: user?.editorSettings?.version === "latest";
89
const defaultPreferToolbox = props.ideSettings?.preferToolbox ?? user?.editorSettings?.preferToolbox ?? false;
90
const [useLatestIde, setUseLatestIde] = useState(defaultLatestIde);
91
const [preferToolbox, setPreferToolbox] = useState(defaultPreferToolbox);
92
// Note: it has data fetching and UI rendering race between the updating of `selectedProjectId` and `selectedIde`
93
// We have to stored the using repositoryId locally so that we can know selectedIde is updated because if which repo
94
// so that it doesn't show ide error messages in middle state
95
const [defaultIdeSource, setDefaultIdeSource] = useState<string | undefined>(selectedProjectID);
96
const {
97
computedDefault: computedDefaultEditor,
98
usingConfigurationId,
99
availableOptions: availableEditorOptions,
100
} = useAllowedWorkspaceEditorsMemo(selectedProjectID, {
101
userDefault: user?.editorSettings?.name,
102
filterOutDisabled: true,
103
});
104
const defaultIde = computedDefaultEditor;
105
const [selectedIde, setSelectedIde, selectedIdeIsDirty] = useDirtyState<string | undefined>(defaultIde);
106
const {
107
computedDefaultClass,
108
data: allowedWorkspaceClasses,
109
isLoading: isLoadingWorkspaceClasses,
110
} = useAllowedWorkspaceClassesMemo(selectedProjectID);
111
const defaultWorkspaceClass = props.workspaceClass ?? computedDefaultClass;
112
const showExamples = props.showExamples ?? false;
113
const { data: orgSettings } = useOrgSettingsQuery();
114
const memberRole = useMemberRole();
115
const [selectedWsClass, setSelectedWsClass, selectedWsClassIsDirty] = useDirtyState(defaultWorkspaceClass);
116
const [errorWsClass, setErrorWsClass] = useState<ReactNode | undefined>(undefined);
117
const [errorIde, setErrorIde] = useState<ReactNode | undefined>(undefined);
118
const [warningIde, setWarningIde] = useState<ReactNode | undefined>(undefined);
119
const [contextURL, setContextURL] = useState<string | undefined>(
120
StartWorkspaceOptions.parseContextUrl(location.hash),
121
);
122
const [nextLoadOption, setNextLoadOption] = useState<NextLoadOption>("searchParams");
123
const workspaceContext = useWorkspaceContext(contextURL);
124
const needsGitAuthorization = useNeedsGitAuthorization();
125
126
useEffect(() => {
127
setContextURL(StartWorkspaceOptions.parseContextUrl(location.hash));
128
setSelectedProjectID(undefined);
129
setNextLoadOption("searchParams");
130
}, [location.hash]);
131
132
const cloneURL = workspaceContext.data?.cloneUrl;
133
134
const paginatedConfigurations = useListConfigurations({
135
sortBy: "name",
136
sortOrder: "desc",
137
pageSize: 100,
138
searchTerm: cloneURL,
139
});
140
const configurations = useMemo<Configuration[]>(
141
() => flattenPagedConfigurations(paginatedConfigurations.data),
142
[paginatedConfigurations.data],
143
);
144
145
const storeAutoStartOptions = useCallback(async () => {
146
if (!workspaceContext.data || !user || !currentOrg) {
147
return;
148
}
149
if (!cloneURL) {
150
return;
151
}
152
let workspaceAutoStartOptions = (user.workspaceAutostartOptions || []).filter(
153
(e) => !(e.cloneUrl === cloneURL && e.organizationId === currentOrg.id),
154
);
155
156
// we only keep the last 40 options
157
workspaceAutoStartOptions = workspaceAutoStartOptions.slice(-40);
158
159
// remember options
160
workspaceAutoStartOptions.push(
161
new User_WorkspaceAutostartOption({
162
cloneUrl: cloneURL,
163
organizationId: currentOrg.id,
164
workspaceClass: selectedWsClass,
165
editorSettings: new EditorReference({
166
name: selectedIde,
167
version: useLatestIde ? "latest" : "stable",
168
preferToolbox: preferToolbox,
169
}),
170
}),
171
);
172
const updatedUser = await updateUser.mutateAsync({
173
additionalData: {
174
workspaceAutostartOptions: workspaceAutoStartOptions.map((o) =>
175
converter.fromWorkspaceAutostartOption(o),
176
),
177
},
178
});
179
setUser(updatedUser);
180
}, [
181
workspaceContext.data,
182
user,
183
currentOrg,
184
cloneURL,
185
selectedWsClass,
186
selectedIde,
187
useLatestIde,
188
preferToolbox,
189
updateUser,
190
setUser,
191
]);
192
193
// see if we have a matching configuration based on context url and configuration's repo url
194
const configuration = useMemo(() => {
195
if (!workspaceContext.data || configurations.length === 0) {
196
return undefined;
197
}
198
if (!cloneURL) {
199
return;
200
}
201
// TODO: Account for multiple configurations w/ the same cloneUrl
202
return configurations.find((p) => p.cloneUrl === cloneURL);
203
}, [workspaceContext.data, configurations, cloneURL]);
204
205
// Handle the case where the context url in the hash matches a project and we don't have that project selected yet
206
useEffect(() => {
207
if (configuration && !selectedProjectID) {
208
setSelectedProjectID(configuration.id);
209
}
210
}, [configuration, selectedProjectID]);
211
212
// In addition to updating state, we want to update the url hash as well
213
// This allows the contextURL to persist if user changes orgs, or copies/shares url
214
const handleContextURLChange = useCallback(
215
(repo: SuggestedRepository) => {
216
// we disable auto start if the user changes the context URL
217
setAutostart(false);
218
// TODO: consider storing SuggestedRepository as state vs. discrete props
219
setContextURL(repo?.url);
220
setSelectedProjectID(repo?.configurationId);
221
// TODO: consider dropping this - it's a lossy conversion
222
history.replace(`#${repo?.url}`);
223
// reset load options
224
setNextLoadOption("searchParams");
225
},
226
[history],
227
);
228
229
const onSelectEditorChange = useCallback(
230
(ide: string, useLatest: boolean) => {
231
setSelectedIde(ide);
232
setUseLatestIde(useLatest);
233
},
234
[setSelectedIde, setUseLatestIde],
235
);
236
237
const existingWorkspaces = useMemo(() => {
238
if (!workspaces.data || !workspaceContext.data) {
239
return [];
240
}
241
return workspaces.data.filter(
242
(ws) =>
243
ws.status?.phase?.name === WorkspacePhase_Phase.RUNNING &&
244
workspaceContext.data &&
245
ws.status.gitStatus?.cloneUrl === workspaceContext.data.cloneUrl &&
246
ws.status?.gitStatus?.latestCommit === workspaceContext.data.revision,
247
);
248
}, [workspaces.data, workspaceContext.data]);
249
const [selectAccountError, setSelectAccountError] = useState<SelectAccountPayload | undefined>(undefined);
250
251
const createWorkspace = useCallback(
252
/**
253
* options will omit
254
* - source.url
255
* - source.workspaceClass
256
* - metadata.organizationId
257
* - metadata.configurationId
258
*/
259
async (options?: PartialMessage<CreateAndStartWorkspaceRequest>) => {
260
// add options from search params
261
const opts = options || {};
262
263
if (!contextURL) {
264
return;
265
}
266
267
const organizationId = currentOrg?.id;
268
if (!organizationId) {
269
// We need an organizationId for this group of users
270
console.error("Skipping createWorkspace");
271
return;
272
}
273
274
// if user received an INVALID_GITPOD_YML yml for their contextURL they can choose to proceed using default configuration
275
if (
276
workspaceContext.error &&
277
ApplicationError.hasErrorCode(workspaceContext.error) &&
278
workspaceContext.error.code === ErrorCodes.INVALID_GITPOD_YML
279
) {
280
opts.forceDefaultConfig = true;
281
}
282
283
try {
284
if (createWorkspaceMutation.isStarting) {
285
console.log("Skipping duplicate createWorkspace call.");
286
return;
287
}
288
// we wait at least 5 secs
289
const timeout = new Promise((resolve) => setTimeout(resolve, 5000));
290
291
if (!opts.metadata) {
292
opts.metadata = {};
293
}
294
opts.metadata.organizationId = organizationId;
295
opts.metadata.configurationId = selectedProjectID;
296
297
const contextUrlSource: PartialMessage<CreateAndStartWorkspaceRequest_ContextURL> =
298
opts.source?.case === "contextUrl" ? opts.source?.value ?? {} : {};
299
contextUrlSource.url = contextURL;
300
contextUrlSource.workspaceClass = selectedWsClass;
301
if (!contextUrlSource.editor || !contextUrlSource.editor.name) {
302
contextUrlSource.editor = {
303
name: selectedIde,
304
version: useLatestIde ? "latest" : undefined,
305
preferToolbox: preferToolbox,
306
};
307
}
308
opts.source = {
309
case: "contextUrl",
310
value: contextUrlSource,
311
};
312
const result = await createWorkspaceMutation.createWorkspace(opts);
313
await storeAutoStartOptions();
314
await timeout;
315
if (result.workspace?.status?.workspaceUrl) {
316
window.location.href = result.workspace.status.workspaceUrl;
317
} else if (result.workspace!.id) {
318
history.push(`/start/#${result.workspace!.id}`);
319
}
320
} catch (error) {
321
console.log(error);
322
} finally {
323
// we only auto start once, so we don't run into endless start loops on errors
324
if (autostart) {
325
setAutostart(false);
326
}
327
}
328
},
329
[
330
workspaceContext.error,
331
contextURL,
332
currentOrg?.id,
333
selectedWsClass,
334
selectedIde,
335
useLatestIde,
336
preferToolbox,
337
createWorkspaceMutation,
338
selectedProjectID,
339
storeAutoStartOptions,
340
history,
341
autostart,
342
],
343
);
344
345
// listen on auto start changes
346
useEffect(() => {
347
if (!autostart || nextLoadOption !== "allDone") {
348
return;
349
}
350
createWorkspace();
351
}, [autostart, nextLoadOption, createWorkspace]);
352
353
useEffect(() => {
354
if (nextLoadOption !== "searchParams") {
355
return;
356
}
357
if (props.ideSettings?.defaultIde) {
358
setSelectedIde(props.ideSettings.defaultIde);
359
}
360
if (props.workspaceClass) {
361
setSelectedWsClass(props.workspaceClass);
362
}
363
setNextLoadOption("autoStart");
364
}, [props, setSelectedIde, setSelectedWsClass, nextLoadOption, setNextLoadOption]);
365
366
// when workspaceContext is available, we look up if options are remembered
367
useEffect(() => {
368
if (!workspaceContext.data || !user?.workspaceAutostartOptions || !currentOrg) {
369
return;
370
}
371
const cloneURL = workspaceContext.data.cloneUrl;
372
if (!cloneURL) {
373
return undefined;
374
}
375
if (nextLoadOption !== "autoStart") {
376
return;
377
}
378
if (isLoadingWorkspaceClasses || allowedWorkspaceClasses.length === 0) {
379
return;
380
}
381
const rememberedOptions = user.workspaceAutostartOptions.find(
382
(e) => e.cloneUrl === cloneURL && e.organizationId === currentOrg?.id,
383
);
384
if (rememberedOptions) {
385
if (!selectedIdeIsDirty) {
386
if (
387
rememberedOptions.editorSettings?.name &&
388
!availableEditorOptions.includes(rememberedOptions.editorSettings.name)
389
) {
390
rememberedOptions.editorSettings.name = "code";
391
}
392
setSelectedIde(rememberedOptions.editorSettings?.name, false);
393
setUseLatestIde(rememberedOptions.editorSettings?.version === "latest");
394
setPreferToolbox(rememberedOptions.editorSettings?.preferToolbox || false);
395
}
396
397
if (!selectedWsClassIsDirty) {
398
if (
399
allowedWorkspaceClasses.some(
400
(cls) => cls.id === rememberedOptions.workspaceClass && !cls.isDisabledInScope,
401
)
402
) {
403
setSelectedWsClass(rememberedOptions.workspaceClass, false);
404
}
405
}
406
} else {
407
// reset the ide settings to the user's default IF they haven't changed it manually
408
if (!selectedIdeIsDirty) {
409
setSelectedIde(defaultIde, false);
410
setUseLatestIde(defaultLatestIde);
411
setPreferToolbox(defaultPreferToolbox);
412
}
413
if (!selectedWsClassIsDirty) {
414
const projectWsClass = configuration?.workspaceSettings?.workspaceClass;
415
const targetClass = projectWsClass || defaultWorkspaceClass;
416
if (allowedWorkspaceClasses.some((cls) => cls.id === targetClass && !cls.isDisabledInScope)) {
417
setSelectedWsClass(targetClass, false);
418
}
419
}
420
}
421
setDefaultIdeSource(usingConfigurationId);
422
setNextLoadOption("allDone");
423
// we only update the remembered options when the workspaceContext changes
424
// eslint-disable-next-line react-hooks/exhaustive-deps
425
}, [workspaceContext.data, nextLoadOption, configuration, isLoadingWorkspaceClasses, allowedWorkspaceClasses]);
426
427
// Need a wrapper here so we call createWorkspace w/o any arguments
428
const onClickCreate = useCallback(() => createWorkspace(), [createWorkspace]);
429
430
// if the context URL has a referrer prefix, we set the referrerIde as the selected IDE and autostart the workspace.
431
useEffect(() => {
432
if (workspaceContext.data && workspaceContext.data.refererIDE) {
433
if (!selectedIdeIsDirty) {
434
setSelectedIde(workspaceContext.data.refererIDE, false);
435
}
436
setAutostart(true);
437
}
438
}, [selectedIdeIsDirty, setSelectedIde, workspaceContext.data]);
439
440
// on error we disable auto start and consider options loaded
441
useEffect(() => {
442
if (workspaceContext.error || createWorkspaceMutation.error) {
443
setAutostart(false);
444
setNextLoadOption("allDone");
445
}
446
}, [workspaceContext.error, createWorkspaceMutation.error]);
447
448
// Derive if the continue button is disabled based on current state
449
const continueButtonDisabled = useMemo(() => {
450
if (
451
autostart ||
452
workspaceContext.isLoading ||
453
!contextURL ||
454
contextURL.length === 0 ||
455
!!errorIde ||
456
!!errorWsClass
457
) {
458
return true;
459
}
460
if (workspaceContext.error) {
461
// For INVALID_GITPOD_YML we don't want to disable the button
462
// The user see a warning that their file is invalid, but they can continue and it will be ignored
463
if (
464
workspaceContext.error &&
465
ApplicationError.hasErrorCode(workspaceContext.error) &&
466
workspaceContext.error.code === ErrorCodes.INVALID_GITPOD_YML
467
) {
468
return false;
469
}
470
return true;
471
}
472
473
return false;
474
}, [autostart, contextURL, errorIde, errorWsClass, workspaceContext.error, workspaceContext.isLoading]);
475
476
useEffect(() => {
477
const onKeyDown = (event: KeyboardEvent) => {
478
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
479
if (!continueButtonDisabled) {
480
event.preventDefault();
481
onClickCreate();
482
}
483
}
484
};
485
window.addEventListener("keydown", onKeyDown);
486
return () => {
487
window.removeEventListener("keydown", onKeyDown);
488
};
489
}, [continueButtonDisabled, onClickCreate]);
490
491
if (SelectAccountPayload.is(selectAccountError)) {
492
return (
493
<SelectAccountModal
494
{...selectAccountError}
495
close={() => {
496
history.push(settingsPathIntegrations);
497
}}
498
/>
499
);
500
}
501
502
if (needsGitAuthorization) {
503
return (
504
<div className="flex flex-col mt-32 mx-auto ">
505
<div className="flex flex-col max-h-screen max-w-xl mx-auto items-center w-full">
506
<Heading1>New Workspace</Heading1>
507
<div className="text-gray-500 text-center text-base">
508
Start a new workspace with the following options.
509
</div>
510
<AuthorizeGit
511
refetch={workspaceContext.refetch}
512
className="mt-12 border-2 border-gray-100 dark:border-gray-800 rounded-lg"
513
/>
514
</div>
515
</div>
516
);
517
}
518
519
if (
520
(createWorkspaceMutation.isStarting || autostart) &&
521
!(createWorkspaceMutation.error || workspaceContext.error)
522
) {
523
return <StartPage phase={WorkspacePhase_Phase.PREPARING} />;
524
}
525
526
return (
527
<div className="container">
528
<Menu />
529
<div className="flex flex-col mt-32 mx-auto ">
530
<div className="flex flex-col max-h-screen max-w-xl mx-auto items-center w-full">
531
<Heading1>New Workspace</Heading1>
532
<div className="text-gray-500 text-center text-base">
533
Create a new workspace in the{" "}
534
<span className="font-semibold text-gray-600 dark:text-gray-400">{currentOrg?.name}</span>{" "}
535
organization.
536
</div>
537
538
<div className="-mx-6 px-6 mt-6 w-full">
539
<MaintenanceModeBanner />
540
{createWorkspaceMutation.error || workspaceContext.error ? (
541
<ErrorMessage
542
error={
543
(createWorkspaceMutation.error as StartWorkspaceError) ||
544
(workspaceContext.error as StartWorkspaceError)
545
}
546
setSelectAccountError={setSelectAccountError}
547
reset={() => {
548
workspaceContext.refetch();
549
createWorkspaceMutation.reset();
550
}}
551
/>
552
) : null}
553
{warningIde && (
554
<Alert type="warning" className="my-4">
555
<span className="text-sm">{warningIde}</span>
556
</Alert>
557
)}
558
{workspaceContext.data?.data.metadata?.warnings.map((warning) => (
559
<Alert type="warning" key={warning}>
560
<span className="text-sm">{warning}</span>
561
</Alert>
562
)) ?? []}
563
564
<InputField>
565
<RepositoryFinder
566
onChange={handleContextURLChange}
567
selectedContextURL={contextURL}
568
selectedConfigurationId={selectedProjectID}
569
expanded={!contextURL}
570
onlyConfigurations={
571
orgSettings?.roleRestrictions.some(
572
(roleRestriction) =>
573
roleRestriction.role === memberRole &&
574
roleRestriction.permissions.includes(
575
OrganizationPermission.START_ARBITRARY_REPOS,
576
),
577
) ?? false
578
}
579
disabled={createWorkspaceMutation.isStarting}
580
showExamples={showExamples}
581
/>
582
</InputField>
583
584
<InputField error={errorIde}>
585
<SelectIDEComponent
586
onSelectionChange={onSelectEditorChange}
587
availableOptions={
588
defaultIdeSource === selectedProjectID ? availableEditorOptions : undefined
589
}
590
setError={setErrorIde}
591
setWarning={setWarningIde}
592
selectedIdeOption={selectedIde}
593
selectedConfigurationId={selectedProjectID}
594
pinnedEditorVersions={
595
orgSettings?.pinnedEditorVersions &&
596
new Map<string, string>(Object.entries(orgSettings.pinnedEditorVersions))
597
}
598
useLatest={useLatestIde}
599
disabled={createWorkspaceMutation.isStarting}
600
loading={workspaceContext.isLoading}
601
ignoreRestrictionScopes={undefined}
602
/>
603
</InputField>
604
605
<InputField error={errorWsClass}>
606
<SelectWorkspaceClassComponent
607
selectedConfigurationId={selectedProjectID}
608
onSelectionChange={setSelectedWsClass}
609
setError={setErrorWsClass}
610
selectedWorkspaceClass={selectedWsClass}
611
disabled={createWorkspaceMutation.isStarting}
612
loading={workspaceContext.isLoading}
613
/>
614
</InputField>
615
</div>
616
<div className="w-full flex justify-end mt-3 space-x-2 px-6">
617
<LoadingButton
618
onClick={onClickCreate}
619
autoFocus={true}
620
className="w-full"
621
loading={createWorkspaceMutation.isStarting || !!autostart}
622
disabled={continueButtonDisabled}
623
>
624
{createWorkspaceMutation.isStarting
625
? "Opening Workspace ..."
626
: `Continue (${StartWorkspaceKeyBinding})`}
627
</LoadingButton>
628
</div>
629
{existingWorkspaces.length > 0 && !createWorkspaceMutation.isStarting && (
630
<div className="w-full flex flex-col justify-end px-6">
631
<p className="mt-6 text-center text-base">Running workspaces on this revision</p>
632
{existingWorkspaces.map((w) => {
633
return (
634
<a
635
key={w.id}
636
href={w.status?.workspaceUrl || `/start/${w.id}}`}
637
className="rounded-xl group hover:bg-gray-100 dark:hover:bg-gray-800 flex"
638
>
639
<WorkspaceEntry info={w} shortVersion={true} />
640
</a>
641
);
642
})}
643
</div>
644
)}
645
</div>
646
</div>
647
{!autostart && <BrowserExtensionBanner />}
648
</div>
649
);
650
}
651
652
function tryAuthorize(host: string, scopes?: string[]): Promise<SelectAccountPayload | undefined> {
653
const result = new Deferred<SelectAccountPayload | undefined>();
654
openAuthorizeWindow({
655
host,
656
scopes,
657
onSuccess: () => {
658
result.resolve();
659
},
660
onError: (error) => {
661
if (typeof error === "string") {
662
try {
663
const payload = JSON.parse(error);
664
if (SelectAccountPayload.is(payload)) {
665
result.resolve(payload);
666
}
667
} catch (error) {
668
console.log(error);
669
}
670
}
671
},
672
}).catch((error) => {
673
console.log(error);
674
});
675
return result.promise;
676
}
677
678
interface ErrorMessageProps {
679
error?: StartWorkspaceError;
680
reset: () => void;
681
setSelectAccountError: (error?: SelectAccountPayload) => void;
682
}
683
const ErrorMessage: FunctionComponent<ErrorMessageProps> = ({ error, reset, setSelectAccountError }) => {
684
if (!error) {
685
return null;
686
}
687
688
switch (error.code) {
689
case ErrorCodes.INVALID_GITPOD_YML:
690
return (
691
<RepositoryInputError
692
title="Invalid YAML configuration; using default settings."
693
message={error.message}
694
/>
695
);
696
case ErrorCodes.NOT_AUTHENTICATED:
697
return (
698
<RepositoryInputError
699
title="You are not authenticated."
700
linkText={`Authorize with ${error.data?.host}`}
701
linkOnClick={() => {
702
tryAuthorize(error.data?.host, error.data?.scopes).then((payload) => {
703
setSelectAccountError(payload);
704
reset();
705
});
706
}}
707
/>
708
);
709
case ErrorCodes.NOT_FOUND:
710
return <RepositoryNotFound error={error} />;
711
case ErrorCodes.PERMISSION_DENIED:
712
return <RepositoryInputError title="Access is not allowed" />;
713
case ErrorCodes.USER_BLOCKED:
714
window.location.href = "/blocked";
715
return null;
716
case ErrorCodes.TOO_MANY_RUNNING_WORKSPACES:
717
return <LimitReachedParallelWorkspacesModal />;
718
case ErrorCodes.INVALID_COST_CENTER:
719
return <RepositoryInputError title={`The organization '${error.data}' is not valid.`} />;
720
case ErrorCodes.PAYMENT_SPENDING_LIMIT_REACHED:
721
return <UsageLimitReachedModal onClose={reset} />;
722
case ErrorCodes.NEEDS_VERIFICATION:
723
return <VerifyModal />;
724
default:
725
// Catch-All error message
726
return (
727
<RepositoryInputError
728
title="We're sorry, there seems to have been an error."
729
message={error.message || JSON.stringify(error)}
730
/>
731
);
732
}
733
};
734
735
type RepositoryInputErrorProps = {
736
type?: "error" | "warning";
737
title: string;
738
message?: string;
739
linkText?: string;
740
linkHref?: string;
741
linkOnClick?: () => void;
742
};
743
const RepositoryInputError: FC<RepositoryInputErrorProps> = ({ title, message, linkText, linkHref, linkOnClick }) => {
744
return (
745
<Alert type="warning">
746
<div>
747
<span className="text-sm font-semibold">{title}</span>
748
{message && (
749
<div className="font-mono text-xs">
750
<span>{message}</span>
751
</div>
752
)}
753
</div>
754
{linkText && (
755
<div>
756
{linkOnClick ? (
757
<LinkButton className="whitespace-nowrap text-sm font-semibold" onClick={linkOnClick}>
758
{linkText}
759
</LinkButton>
760
) : (
761
<a className="gp-link whitespace-nowrap text-sm font-semibold" href={linkHref}>
762
{linkText}
763
</a>
764
)}
765
</div>
766
)}
767
</Alert>
768
);
769
};
770
771
export const RepositoryNotFound: FC<{ error: StartWorkspaceError }> = ({ error }) => {
772
const { host, owner, userIsOwner, userScopes = [], lastUpdate } = error.data || {};
773
774
const authProviders = useAuthProviderDescriptions();
775
const authProvider = authProviders.data?.find((a) => a.host === host);
776
if (!authProvider) {
777
return <RepositoryInputError title="The repository was not found in your account." />;
778
}
779
780
// TODO: this should be aware of already granted permissions
781
const missingScope =
782
authProvider.type === AuthProviderType.GITHUB
783
? "repo"
784
: authProvider.type === AuthProviderType.GITLAB
785
? "api"
786
: "";
787
const authorizeURL = gitpodHostUrl
788
.withApi({
789
pathname: "/authorize",
790
search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${host}&scopes=${missingScope}`,
791
})
792
.toString();
793
794
const errorMessage = error.data?.errorMessage || error.message;
795
796
if (!userScopes.includes(missingScope)) {
797
return (
798
<RepositoryInputError
799
title="The repository may be private. Please authorize Gitpod to access private repositories."
800
message={errorMessage}
801
linkText="Grant access"
802
linkHref={authorizeURL}
803
/>
804
);
805
}
806
807
if (userIsOwner) {
808
return <RepositoryInputError title="The repository was not found in your account." message={errorMessage} />;
809
}
810
811
let updatedRecently = false;
812
if (lastUpdate && typeof lastUpdate === "string") {
813
try {
814
const minutes = (Date.now() - Date.parse(lastUpdate)) / 1000 / 60;
815
updatedRecently = minutes < 5;
816
} catch {
817
// ignore
818
}
819
}
820
821
if (!updatedRecently) {
822
return (
823
<RepositoryInputError
824
title={`Permission to access private repositories has been granted. If you are a member of '${owner}', please try to request access for Gitpod.`}
825
message={errorMessage}
826
linkText="Request access"
827
linkHref={authorizeURL}
828
/>
829
);
830
}
831
if (authProvider.id.toLocaleLowerCase() === "public-github" && isGitpodIo()) {
832
return (
833
<RepositoryInputError
834
title={`Although you appear to have the correct authorization credentials, the '${owner}' organization has enabled OAuth App access restrictions, meaning that data access to third-parties is limited. For more information on these restrictions, including how to enable this app, visit https://docs.github.com/articles/restricting-access-to-your-organization-s-data/.`}
835
message={errorMessage}
836
linkText="Check Organization Permissions"
837
linkHref={"https://github.com/settings/connections/applications/484069277e293e6d2a2a"}
838
/>
839
);
840
}
841
842
return (
843
<RepositoryInputError
844
title={`Your access token was updated recently. Please try again if the repository exists and Gitpod was approved for '${owner}'.`}
845
message={errorMessage}
846
linkText="Authorize again"
847
linkHref={authorizeURL}
848
/>
849
);
850
};
851
852
export function LimitReachedParallelWorkspacesModal() {
853
const { data: installationConfig } = useInstallationConfiguration();
854
const isDedicated = !!installationConfig?.isDedicatedInstallation;
855
856
return (
857
<LimitReachedModal>
858
<p className="mt-1 mb-2 text-base dark:text-gray-400">
859
You have reached the limit of parallel running workspaces for your account.{" "}
860
{!isDedicated
861
? "Please, upgrade or stop one of your running workspaces."
862
: "Please, stop one of your running workspaces or contact your organization owner to change the limit."}
863
</p>
864
</LimitReachedModal>
865
);
866
}
867
868
export function LimitReachedModal(p: { children: ReactNode }) {
869
const user = useCurrentUser();
870
return (
871
// TODO: Use title and buttons props
872
<Modal visible={true} closeable={false} onClose={() => {}}>
873
<ModalHeader>
874
<div className="flex">
875
<span className="flex-grow">Limit Reached</span>
876
<img className="rounded-full w-8 h-8" src={user?.avatarUrl || ""} alt={user?.name || "Anonymous"} />
877
</div>
878
</ModalHeader>
879
<ModalBody>{p.children}</ModalBody>
880
<ModalFooter>
881
<a href={gitpodHostUrl.asDashboard().toString()}>
882
<Button variant="secondary">Go to Dashboard</Button>
883
</a>
884
<a href={gitpodHostUrl.with({ pathname: "plans" }).toString()} className="ml-2">
885
<Button>Upgrade</Button>
886
</a>
887
</ModalFooter>
888
</Modal>
889
);
890
}
891
892