Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/Login.tsx
3606 views
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 * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
8
import { useContext, useEffect, useState, useMemo, useCallback, FC } from "react";
9
import { UserContext } from "./user-context";
10
import { getGitpodService } from "./service/service";
11
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "./provider-utils";
12
import exclamation from "./images/exclamation.svg";
13
import { getURLHash, isTrustedUrlOrPath } from "./utils";
14
import ErrorMessage from "./components/ErrorMessage";
15
import { Heading1, Heading2, Subheading } from "./components/typography/headings";
16
import { SSOLoginForm } from "./login/SSOLoginForm";
17
import { useAuthProviderDescriptions } from "./data/auth-providers/auth-provider-descriptions-query";
18
import { SetupPending } from "./login/SetupPending";
19
import { useNeedsSetup } from "./dedicated-setup/use-needs-setup";
20
import { useInstallationConfiguration } from "./data/installation/installation-config-query";
21
import { AuthProviderDescription } from "@gitpod/public-api/lib/gitpod/v1/authprovider_pb";
22
import { Button, ButtonProps } from "@podkit/buttons/Button";
23
import { cn } from "@podkit/lib/cn";
24
import { userClient } from "./service/public-api";
25
import { ProductLogo } from "./components/ProductLogo";
26
import { useIsDataOps, useFeatureFlag } from "./data/featureflag-query";
27
import { LoadingState } from "@podkit/loading/LoadingState";
28
import { isGitpodIo } from "./utils";
29
import onaWordmark from "./images/ona-wordmark.svg";
30
import onaApplication from "./images/ona-application.webp";
31
32
export function markLoggedIn() {
33
document.cookie = GitpodCookie.generateCookie(window.location.hostname);
34
}
35
36
export function hasLoggedInBefore() {
37
return GitpodCookie.isPresent(document.cookie);
38
}
39
40
const SEGMENT_SEPARATOR = "/";
41
const getContextUrlFromHash = (input: string): URL | undefined => {
42
if (typeof URL.canParse !== "function") {
43
return undefined;
44
}
45
if (URL.canParse(input)) {
46
return new URL(input);
47
}
48
49
const chunks = input.split(SEGMENT_SEPARATOR);
50
for (const chunk of chunks) {
51
input = input.replace(`${chunk}${SEGMENT_SEPARATOR}`, "");
52
if (URL.canParse(input)) {
53
return new URL(input);
54
}
55
}
56
57
return undefined;
58
};
59
60
type LoginProps = {
61
onLoggedIn?: () => void;
62
};
63
export const Login: FC<LoginProps> = ({ onLoggedIn }) => {
64
const urlHash = useMemo(() => getURLHash(), []);
65
const authProviders = useAuthProviderDescriptions();
66
const [hostFromContext, setHostFromContext] = useState<string | undefined>();
67
const [repoPathname, setRepoPathname] = useState<string | undefined>();
68
69
const { data: installationConfig } = useInstallationConfiguration();
70
const enterprise = !!installationConfig?.isDedicatedInstallation;
71
72
useEffect(() => {
73
try {
74
if (urlHash.length > 0) {
75
const url = new URL(urlHash);
76
setHostFromContext(url.host);
77
setRepoPathname(url.pathname);
78
}
79
} catch (error) {
80
// hash is not a valid URL, try to extract the context URL when there are parts like env vars or other prefixes
81
const contextUrl = getContextUrlFromHash(urlHash);
82
if (contextUrl) {
83
setHostFromContext(contextUrl.host);
84
setRepoPathname(contextUrl.pathname);
85
}
86
}
87
}, [urlHash]);
88
89
const providerFromContext =
90
(hostFromContext && authProviders.data?.find((provider) => provider.host === hostFromContext)) || undefined;
91
92
if (authProviders.isLoading) {
93
return <LoadingState />;
94
}
95
96
return (
97
<div
98
id="login-container"
99
className="z-50 flex flex-col-reverse lg:flex-row w-full min-h-screen"
100
style={
101
!enterprise
102
? {
103
background:
104
"linear-gradient(390deg, #1F1329 0%, #333A75 20%, #556CA8 50%, #90A898 60%, #90A898 70%, #E2B15C 90%, #BEA462 100%)",
105
}
106
: undefined
107
}
108
>
109
{enterprise ? (
110
<EnterpriseLoginWrapper
111
onLoggedIn={onLoggedIn}
112
providerFromContext={providerFromContext}
113
repoPathname={repoPathname}
114
/>
115
) : (
116
<PAYGLoginWrapper
117
onLoggedIn={onLoggedIn}
118
providerFromContext={providerFromContext}
119
repoPathname={repoPathname}
120
/>
121
)}
122
</div>
123
);
124
};
125
126
// TODO: Do we really want a different style button for the login page, or could we use our normal secondary variant?
127
type LoginButtonProps = {
128
onClick: ButtonProps["onClick"];
129
};
130
const LoginButton: FC<LoginButtonProps> = ({ children, onClick }) => {
131
return (
132
<Button
133
// Using ghost here to avoid the default button styles
134
variant="ghost"
135
// TODO: Determine if we want this one-off style of button
136
className={cn(
137
"border-none bg-gray-100 hover:bg-gray-200 text-gray-500 dark:text-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 hover:opacity-100",
138
"flex-none w-56 h-10 p-0 inline-flex rounded-xl",
139
"justify-normal",
140
)}
141
onClick={onClick}
142
>
143
{children}
144
</Button>
145
);
146
};
147
148
type LoginWrapperProps = LoginProps & {
149
providerFromContext?: AuthProviderDescription;
150
repoPathname?: string;
151
};
152
153
const PAYGLoginWrapper: FC<LoginWrapperProps> = ({ providerFromContext, repoPathname, onLoggedIn }) => {
154
return (
155
<div className="flex flex-col md:flex-row w-full">
156
<div
157
id="login-section"
158
// for some reason, min-h-dvh does not work, so we need tailwind's arbitrary values
159
className="w-full min-h-[100dvh] lg:w-full flex flex-col justify-center items-center p-2"
160
>
161
<div
162
id="login-section-column"
163
className="bg-white dark:bg-[#161616] flex-grow rounded-xl w-full flex flex-col h-100 mx-auto"
164
>
165
{
166
<div className="flex-grow h-100 flex flex-col items-center justify-center">
167
<LoginContent
168
providerFromContext={providerFromContext}
169
onLoggedIn={onLoggedIn}
170
repoPathname={repoPathname}
171
/>
172
</div>
173
}
174
<TermsOfServiceAndPrivacyPolicy />
175
</div>
176
</div>
177
<RightProductDescriptionPanel />
178
</div>
179
);
180
};
181
182
const EnterpriseLoginWrapper: FC<LoginWrapperProps> = ({ providerFromContext, repoPathname, onLoggedIn }) => {
183
// This flag lets us know if the current installation still needs setup
184
const { needsSetup, isLoading: needsSetupCheckLoading } = useNeedsSetup();
185
186
return (
187
<div id="login-section" className="flex-grow flex w-full">
188
<div id="login-section-column" className="flex-grow max-w-2xl flex flex-col h-100 mx-auto">
189
{needsSetupCheckLoading ? (
190
<div className="flex-grow" />
191
) : needsSetup ? (
192
<SetupPending alwaysShowHeader />
193
) : (
194
<div className="flex-grow h-100 flex flex-row items-center justify-center">
195
<LoginContent
196
providerFromContext={providerFromContext}
197
onLoggedIn={onLoggedIn}
198
repoPathname={repoPathname}
199
/>
200
</div>
201
)}
202
{!needsSetup && !needsSetupCheckLoading && <TermsOfServiceAndPrivacyPolicy />}
203
</div>
204
</div>
205
);
206
};
207
208
const LoginContent = ({
209
providerFromContext,
210
repoPathname,
211
onLoggedIn,
212
}: {
213
providerFromContext?: AuthProviderDescription;
214
repoPathname?: string;
215
onLoggedIn?: () => void;
216
}) => {
217
const { setUser } = useContext(UserContext);
218
const isDataOps = useIsDataOps();
219
const isGitpodIoUser = isGitpodIo();
220
const classicSunsetConfig = useFeatureFlag("classic_payg_sunset_enabled");
221
222
const authProviders = useAuthProviderDescriptions();
223
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
224
225
const { data: installationConfig } = useInstallationConfiguration();
226
const enterprise = !!installationConfig?.isDedicatedInstallation;
227
228
// Check if user wants to see all login options (for exempted orgs)
229
const searchParams = useMemo(() => new URLSearchParams(window.location.search), []);
230
const oldLogin = searchParams.get("oldLogin") === "true";
231
232
// Show sunset UI only if: sunset enabled, on gitpod.io, and user hasn't requested old login
233
const showSunsetUI =
234
(typeof classicSunsetConfig === "object" ? classicSunsetConfig.enabled : false) && isGitpodIoUser && !oldLogin;
235
236
const updateUser = useCallback(async () => {
237
await getGitpodService().reconnect();
238
const { user } = await userClient.getAuthenticatedUser({});
239
if (user) {
240
setUser(user);
241
markLoggedIn();
242
}
243
}, [setUser]);
244
245
const authorizeSuccessful = useCallback(async () => {
246
updateUser().catch(console.error);
247
248
onLoggedIn && onLoggedIn();
249
250
const returnToPath = new URLSearchParams(window.location.search).get("returnToPath");
251
if (returnToPath) {
252
const isAbsoluteURL = /^https?:\/\//i.test(returnToPath);
253
if (!isAbsoluteURL && isTrustedUrlOrPath(returnToPath)) {
254
window.location.replace(returnToPath);
255
}
256
}
257
}, [onLoggedIn, updateUser]);
258
259
const openLogin = useCallback(
260
async (host: string) => {
261
setErrorMessage(undefined);
262
263
try {
264
await openAuthorizeWindow({
265
login: true,
266
host,
267
onSuccess: authorizeSuccessful,
268
onError: (payload) => {
269
let errorMessage: string;
270
if (typeof payload === "string") {
271
errorMessage = payload;
272
} else {
273
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
274
if (payload.error === "email_taken") {
275
errorMessage = `Email address already used in another account. Please log in with ${
276
(payload as any).host
277
}.`;
278
}
279
}
280
setErrorMessage(errorMessage);
281
},
282
});
283
} catch (error) {
284
console.log(error);
285
}
286
},
287
[authorizeSuccessful],
288
);
289
290
return (
291
<div className="rounded-xl px-10 py-10 mx-auto w-full max-w-lg">
292
<div className="mx-auto pb-8">
293
<ProductLogo className="h-14 mx-auto block" />
294
</div>
295
296
<div className="mx-auto text-center pb-8 space-y-2">
297
{isDataOps ? (
298
<Heading1>Log in to DataOps.live Develop</Heading1>
299
) : providerFromContext ? (
300
<>
301
<Heading2>Open a cloud development environment</Heading2>
302
<Subheading>for the repository {repoPathname?.slice(1)}</Subheading>
303
</>
304
) : showSunsetUI ? (
305
<>
306
<Heading1>Start building with Ona</Heading1>
307
<Subheading>What do you want to get done today?</Subheading>
308
</>
309
) : !isGitpodIoUser ? (
310
<Heading1>Log in to Gitpod</Heading1>
311
) : (
312
<>
313
<Heading1>Start building with Ona</Heading1>
314
<Subheading>What do you want to get done today?</Subheading>
315
</>
316
)}
317
</div>
318
319
<div className="w-56 mx-auto flex flex-col space-y-3 items-center">
320
{showSunsetUI ? (
321
<>
322
<Button
323
className="w-full"
324
onClick={() => {
325
window.location.href = "https://app.ona.com/login";
326
}}
327
>
328
Continue with Ona
329
</Button>
330
<div className="mt-4 text-center text-sm">
331
<p className="text-gray-500 dark:text-gray-400">
332
Need to access your organization?{" "}
333
<a
334
href={`${window.location.pathname}?oldLogin=true${
335
searchParams.get("returnToPath")
336
? `&returnToPath=${encodeURIComponent(searchParams.get("returnToPath")!)}`
337
: ""
338
}`}
339
className="gp-link hover:text-gray-600"
340
>
341
Show all login options
342
</a>
343
</p>
344
</div>
345
</>
346
) : providerFromContext ? (
347
<LoginButton
348
key={"button" + providerFromContext.host}
349
onClick={() => openLogin(providerFromContext!.host)}
350
>
351
{iconForAuthProvider(providerFromContext.type)}
352
<span className="pt-2 pb-2 mr-3 text-sm my-auto font-medium truncate overflow-ellipsis">
353
Continue with {simplifyProviderName(providerFromContext.host)}
354
</span>
355
</LoginButton>
356
) : (
357
authProviders.data?.map((ap) => (
358
<LoginButton key={"button" + ap.host} onClick={() => openLogin(ap.host)}>
359
{iconForAuthProvider(ap.type)}
360
<span className="pt-2 pb-2 mr-3 text-sm my-auto font-medium truncate overflow-ellipsis">
361
Continue with {simplifyProviderName(ap.host)}
362
</span>
363
</LoginButton>
364
))
365
)}
366
{!showSunsetUI && <SSOLoginForm onSuccess={authorizeSuccessful} />}
367
</div>
368
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
369
370
{/* Gitpod Classic sunset notice - only show for non-enterprise */}
371
{!enterprise && (
372
<div className="mt-6 text-center text-sm">
373
<p className="text-pk-content-primary">
374
Gitpod Classic has been sunset on Oct 15.{" "}
375
<a
376
href="https://ona.com/stories/gitpod-is-now-ona"
377
target="_blank"
378
rel="noopener noreferrer"
379
className="gp-link hover:text-gray-600"
380
>
381
{" "}
382
Gitpod is now Ona
383
</a>
384
,{" "}
385
<a
386
href="https://app.ona.com"
387
target="_blank"
388
rel="noopener noreferrer"
389
className="gp-link hover:text-gray-600"
390
>
391
start for free
392
</a>{" "}
393
and get $100 credits.
394
</p>
395
</div>
396
)}
397
</div>
398
);
399
};
400
401
const RightProductDescriptionPanel = () => {
402
return (
403
<div className="w-full lg:max-w-lg 2xl:max-w-[600px] flex flex-col justify-center px-4 lg:px-4 md:min-h-screen my-auto">
404
<div className="rounded-lg flex flex-col gap-6 text-white h-full py-4 lg:py-6 max-w-lg mx-auto w-full">
405
<div className="relative bg-white/10 backdrop-blur-sm rounded-lg pt-4 px-4 -mt-2">
406
<div className="flex justify-center pt-4 mb-4">
407
<img src={onaWordmark} alt="ONA" className="w-36" draggable="false" />
408
</div>
409
<div className="relative overflow-hidden">
410
<img
411
src={onaApplication}
412
alt="Ona application preview"
413
className="w-full h-auto rounded-lg shadow-lg translate-y-8"
414
draggable="false"
415
/>
416
</div>
417
</div>
418
419
<div className="flex flex-col gap-4 flex-1">
420
<h2 className="text-white text-xl font-bold leading-tight text-start max-w-md mx-auto">
421
Ona - parallel SWE agents in the cloud, sandboxed for high-autonomy. <br />
422
<br />{" "}
423
<a
424
href="https://app.ona.com"
425
target="_blank"
426
rel="noreferrer"
427
className="underline hover:no-underline"
428
>
429
Start for free
430
</a>{" "}
431
and get $100 in credits. <br />
432
<br />
433
Gitpod Classic has been sunset on Oct 15 |{" "}
434
<a
435
href="https://ona.com/stories/gitpod-classic-payg-sunset"
436
target="_blank"
437
rel="noreferrer"
438
className="underline hover:no-underline"
439
>
440
Learn more
441
</a>
442
</h2>
443
444
<div className="space-y-3 mt-4">
445
<p className="text-white/70 text-base">
446
Delegate software tasks to Ona. It writes code, runs tests, and opens a pull request. Or
447
jump in to inspect output or pair program in your IDE.
448
</p>
449
</div>
450
451
<div className="mt-4">
452
<a
453
href="https://app.ona.com"
454
target="_blank"
455
rel="noopener noreferrer"
456
className="w-full bg-white/20 backdrop-blur-sm text-white font-medium py-2.5 px-4 rounded-lg hover:bg-white/30 transition-colors border border-white/20 inline-flex items-center justify-center gap-2 text-sm"
457
>
458
Try Ona <span className="font-bold">↗</span>
459
</a>
460
</div>
461
</div>
462
</div>
463
</div>
464
);
465
};
466
467
const TermsOfServiceAndPrivacyPolicy = () => {
468
return (
469
<div className="flex-none mx-auto text-center px-4 pb-4">
470
<span className="text-gray-400 dark:text-gray-500 text-sm">
471
By signing in, you agree to our{" "}
472
<a className="gp-link hover:text-gray-600" target="gitpod-terms" href="https://www.gitpod.io/terms/">
473
terms of service
474
</a>{" "}
475
and{" "}
476
<a
477
className="gp-link hover:text-gray-600"
478
target="gitpod-privacy"
479
href="https://www.gitpod.io/privacy/"
480
>
481
privacy policy
482
</a>
483
.
484
</span>
485
</div>
486
);
487
};
488
489