Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/auth/sign-up.tsx
5928 views
1
/*
2
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { Alert, Button, Checkbox, Divider, Input } from "antd";
7
import { CSSProperties, useCallback, useEffect, useRef, useState } from "react";
8
import {
9
GoogleReCaptchaProvider,
10
useGoogleReCaptcha,
11
} from "react-google-recaptcha-v3";
12
import { debounce } from "lodash";
13
14
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
15
16
import Markdown from "@cocalc/frontend/editors/slate/static-markdown";
17
import {
18
MAX_PASSWORD_LENGTH,
19
MIN_PASSWORD_LENGTH,
20
MIN_PASSWORD_STRENGTH,
21
} from "@cocalc/util/auth";
22
import {
23
CONTACT_TAG,
24
CONTACT_THESE_TAGS,
25
} from "@cocalc/util/db-schema/accounts";
26
import {
27
is_valid_email_address as isValidEmailAddress,
28
len,
29
plural,
30
smallIntegerToEnglishWord,
31
} from "@cocalc/util/misc";
32
import { COLORS } from "@cocalc/util/theme";
33
import { Strategy } from "@cocalc/util/types/sso";
34
import { Paragraph } from "components/misc";
35
import A from "components/misc/A";
36
import Loading from "components/share/loading";
37
import apiPost from "lib/api/post";
38
import useCustomize from "lib/use-customize";
39
import AuthPageContainer from "./fragments/auth-page-container";
40
import SSO, { RequiredSSO, useRequiredSSO } from "./sso";
41
import Tags from "./tags";
42
43
const LINE: CSSProperties = { margin: "15px 0" } as const;
44
45
interface SignUpProps {
46
minimal?: boolean; // use a minimal interface with less explanation and instructions (e.g., for embedding in other pages)
47
requiresToken?: boolean; // will be determined by API call if not given.
48
onSuccess?: () => void; // if given, call after sign up *succeeds*.
49
has_site_license?: boolean;
50
publicPathId?: string;
51
showSignIn?: boolean;
52
signInAction?: () => void; // if given, replaces the default sign-in link behavior.
53
requireTags: boolean;
54
}
55
56
export default function SignUp(props: SignUpProps) {
57
const { reCaptchaKey } = useCustomize();
58
59
const body = <SignUp0 {...props} />;
60
if (reCaptchaKey == null) {
61
return body;
62
}
63
64
return (
65
<GoogleReCaptchaProvider reCaptchaKey={reCaptchaKey}>
66
{body}
67
</GoogleReCaptchaProvider>
68
);
69
}
70
71
function SignUp0({
72
requiresToken,
73
minimal,
74
onSuccess,
75
has_site_license,
76
publicPathId,
77
signInAction,
78
showSignIn,
79
requireTags,
80
}: SignUpProps) {
81
const {
82
anonymousSignup,
83
anonymousSignupLicensedShares,
84
siteName,
85
emailSignup,
86
accountCreationInstructions,
87
reCaptchaKey,
88
onCoCalcCom,
89
} = useCustomize();
90
const [tags, setTags] = useState<Set<string>>(new Set());
91
const [signupReason, setSignupReason] = useState<string>("");
92
const [email, setEmail] = useState<string>("");
93
const [registrationToken, setRegistrationToken] = useState<string>("");
94
const [password, setPassword] = useState<string>("");
95
const [firstName, setFirstName] = useState<string>("");
96
const [lastName, setLastName] = useState<string>("");
97
const [signingUp, setSigningUp] = useState<boolean>(false);
98
const [passwordStrength, setPasswordStrength] = useState<{
99
score: number;
100
help?: string;
101
}>({ score: 0 });
102
const [checkingPassword, setCheckingPassword] = useState<boolean>(false);
103
const [issues, setIssues] = useState<{
104
email?: string;
105
password?: string;
106
error?: string;
107
registrationToken?: string;
108
reCaptcha?: string;
109
}>({});
110
111
const minTags = requireTags ? 1 : 0;
112
const showContact = CONTACT_THESE_TAGS.some((t) => tags.has(t));
113
const requestContact = tags.has(CONTACT_TAG) && showContact;
114
115
const submittable = useRef<boolean>(false);
116
const { executeRecaptcha } = useGoogleReCaptcha();
117
const { strategies, supportVideoCall } = useCustomize();
118
119
// Sometimes the user if this component knows requiresToken and sometimes they don't.
120
// If they don't, we have to make an API call to figure it out.
121
const [requiresToken2, setRequiresToken2] = useState<boolean | undefined>(
122
requiresToken,
123
);
124
125
useEffect(() => {
126
if (requiresToken2 === undefined) {
127
(async () => {
128
try {
129
setRequiresToken2(await apiPost("/auth/requires-token"));
130
} catch (err) {}
131
})();
132
}
133
}, []);
134
135
// Debounced password strength checking with reuse-in-flight protection
136
const debouncedCheckPassword = useCallback(
137
debounce((password: string) => {
138
checkPasswordStrengthReuseInFlight(password);
139
}, 100),
140
[],
141
);
142
143
useEffect(() => {
144
if (!password) {
145
setPasswordStrength({ score: 0 });
146
return;
147
}
148
149
debouncedCheckPassword(password);
150
}, [password, debouncedCheckPassword]);
151
152
// based on email: if user has to sign up via SSO, this will tell which strategy to use.
153
const requiredSSO = useRequiredSSO(strategies, email);
154
155
if (requiresToken2 === undefined || strategies == null) {
156
return <Loading />;
157
}
158
159
// number of tags except for the one name "CONTACT_TAG"
160
const tagsSize = tags.size - (requestContact ? 1 : 0);
161
const needsTags = !minimal && onCoCalcCom && tagsSize < minTags;
162
const what = "role";
163
164
submittable.current = !!(
165
requiredSSO == null &&
166
(!requiresToken2 || registrationToken) &&
167
email &&
168
isValidEmailAddress(email) &&
169
password &&
170
password.length >= MIN_PASSWORD_LENGTH &&
171
passwordStrength.score > MIN_PASSWORD_STRENGTH &&
172
firstName?.trim() &&
173
lastName?.trim() &&
174
!needsTags
175
);
176
177
async function signUp() {
178
if (signingUp) return;
179
setIssues({});
180
try {
181
setSigningUp(true);
182
183
let reCaptchaToken: undefined | string;
184
if (reCaptchaKey) {
185
if (!executeRecaptcha) {
186
throw Error("Please wait a few seconds, then try again.");
187
}
188
reCaptchaToken = await executeRecaptcha("signup");
189
}
190
191
const result = await apiPost("/auth/sign-up", {
192
terms: true,
193
email,
194
password,
195
firstName,
196
lastName,
197
registrationToken,
198
reCaptchaToken,
199
publicPathId,
200
tags: Array.from(tags),
201
signupReason,
202
});
203
if (result.issues && len(result.issues) > 0) {
204
setIssues(result.issues);
205
} else {
206
onSuccess?.();
207
}
208
} catch (err) {
209
setIssues({ error: `${err}` });
210
} finally {
211
setSigningUp(false);
212
}
213
}
214
215
async function checkPasswordStrength(password: string) {
216
if (!password || password.length < MIN_PASSWORD_LENGTH) {
217
setPasswordStrength({ score: 0 });
218
return;
219
}
220
221
setCheckingPassword(true);
222
try {
223
const result = await apiPost("/auth/password-strength", { password });
224
setPasswordStrength(result);
225
} catch (err) {
226
// If the API fails, fall back to basic length check
227
setPasswordStrength({
228
score: password.length >= MIN_PASSWORD_LENGTH ? 1 : 0,
229
});
230
} finally {
231
setCheckingPassword(false);
232
}
233
}
234
235
// Wrap the function to prevent concurrent calls
236
const checkPasswordStrengthReuseInFlight = reuseInFlight(
237
checkPasswordStrength,
238
);
239
240
if (!emailSignup && strategies.length == 0) {
241
return (
242
<Alert
243
style={{ margin: "30px 15%" }}
244
type="error"
245
showIcon
246
message={"No Account Creation Allowed"}
247
description={
248
<div style={{ fontSize: "14pt", marginTop: "20px" }}>
249
<b>
250
There is no method enabled for creating an account on this server.
251
</b>
252
{(anonymousSignup ||
253
(anonymousSignupLicensedShares && has_site_license)) && (
254
<>
255
<br />
256
<br />
257
However, you can still{" "}
258
<A href="/auth/try">
259
try {siteName} without creating an account.
260
</A>
261
</>
262
)}
263
</div>
264
}
265
/>
266
);
267
}
268
269
function renderFooter() {
270
return (
271
(!minimal || showSignIn) && (
272
<>
273
<div>
274
Already have an account?{" "}
275
{signInAction ? (
276
<a onClick={signInAction}>Sign In</a>
277
) : (
278
<A href="/auth/sign-in">Sign In</A>
279
)}{" "}
280
{anonymousSignup && (
281
<>
282
or{" "}
283
<A href="/auth/try">
284
{" "}
285
try {siteName} without creating an account.{" "}
286
</A>
287
</>
288
)}
289
</div>
290
</>
291
)
292
);
293
}
294
295
function renderError() {
296
return (
297
issues.error && (
298
<Alert style={LINE} type="error" showIcon message={issues.error} />
299
)
300
);
301
}
302
303
function renderSubtitle() {
304
return (
305
<>
306
<h4 style={{ color: COLORS.GRAY_M, marginBottom: "35px" }}>
307
Start collaborating for free today.
308
</h4>
309
{accountCreationInstructions && (
310
<Markdown value={accountCreationInstructions} />
311
)}
312
</>
313
);
314
}
315
316
return (
317
<AuthPageContainer
318
error={renderError()}
319
footer={renderFooter()}
320
subtitle={renderSubtitle()}
321
minimal={minimal}
322
title={`Create a free account with ${siteName}`}
323
>
324
<Paragraph>
325
By creating an account, you agree to the{" "}
326
<A external={true} href="/policies/terms">
327
Terms of Service
328
</A>
329
.
330
</Paragraph>
331
{onCoCalcCom && supportVideoCall ? (
332
<Paragraph>
333
Do you need more information how {siteName} can be useful for you?{" "}
334
<A href={supportVideoCall}>Book a video call</A> and we'll help you
335
decide.
336
</Paragraph>
337
) : undefined}
338
<Divider />
339
{!minimal && onCoCalcCom ? (
340
<Tags
341
setTags={setTags}
342
signupReason={signupReason}
343
setSignupReason={setSignupReason}
344
tags={tags}
345
minTags={minTags}
346
what={what}
347
style={{ width: "880px", maxWidth: "100%", marginTop: "20px" }}
348
contact={showContact}
349
warning={needsTags}
350
/>
351
) : undefined}
352
<form>
353
{issues.reCaptcha ? (
354
<Alert
355
style={LINE}
356
type="error"
357
showIcon
358
message={issues.reCaptcha}
359
description={<>You may have to contact the site administrator.</>}
360
/>
361
) : undefined}
362
{issues.registrationToken && (
363
<Alert
364
style={LINE}
365
type="error"
366
showIcon
367
message={issues.registrationToken}
368
description={
369
<>
370
You may have to contact the site administrator for a
371
registration token.
372
</>
373
}
374
/>
375
)}
376
{requiresToken2 && (
377
<div style={LINE}>
378
<p>Registration Token</p>
379
<Input
380
style={{ fontSize: "12pt" }}
381
value={registrationToken}
382
placeholder="Enter your secret registration token"
383
onChange={(e) => setRegistrationToken(e.target.value)}
384
/>
385
</div>
386
)}
387
<EmailOrSSO
388
email={email}
389
setEmail={setEmail}
390
signUp={signUp}
391
strategies={strategies}
392
hideSSO={requiredSSO != null}
393
/>
394
<RequiredSSO strategy={requiredSSO} />
395
{issues.email && (
396
<Alert
397
style={LINE}
398
type="error"
399
showIcon
400
message={issues.email}
401
description={
402
<>
403
Choose a different email address,{" "}
404
<A href="/auth/sign-in">sign in</A>, or{" "}
405
<A href="/auth/password-reset">reset your password</A>.
406
</>
407
}
408
/>
409
)}
410
{requiredSSO == null && (
411
<div style={LINE}>
412
<p>Password</p>
413
<Input.Password
414
style={{ fontSize: "12pt" }}
415
value={password}
416
placeholder="Password"
417
autoComplete="new-password"
418
onChange={(e) => setPassword(e.target.value)}
419
onPressEnter={signUp}
420
maxLength={MAX_PASSWORD_LENGTH}
421
/>
422
{password && password.length >= MIN_PASSWORD_LENGTH && (
423
<div style={{ marginTop: "8px" }}>
424
<PasswordStrengthIndicator
425
score={passwordStrength.score}
426
help={passwordStrength.help}
427
checking={checkingPassword}
428
/>
429
</div>
430
)}
431
</div>
432
)}
433
{issues.password && (
434
<Alert style={LINE} type="error" showIcon message={issues.password} />
435
)}
436
{requiredSSO == null && (
437
<div style={LINE}>
438
<p>First name (Given name)</p>
439
<Input
440
style={{ fontSize: "12pt" }}
441
placeholder="First name"
442
value={firstName}
443
onChange={(e) => setFirstName(e.target.value)}
444
onPressEnter={signUp}
445
/>
446
</div>
447
)}
448
{requiredSSO == null && (
449
<div style={LINE}>
450
<p>Last name (Family name)</p>
451
<Input
452
style={{ fontSize: "12pt" }}
453
placeholder="Last name"
454
value={lastName}
455
onChange={(e) => setLastName(e.target.value)}
456
onPressEnter={signUp}
457
/>
458
</div>
459
)}
460
</form>
461
<div style={LINE}>
462
<Button
463
shape="round"
464
size="large"
465
disabled={!submittable.current || signingUp}
466
type="primary"
467
style={{
468
width: "100%",
469
marginTop: "15px",
470
color:
471
!submittable.current || signingUp
472
? COLORS.ANTD_RED_WARN
473
: undefined,
474
}}
475
onClick={signUp}
476
>
477
{needsTags && tagsSize < minTags
478
? `Select at least ${smallIntegerToEnglishWord(minTags)} ${plural(
479
minTags,
480
what,
481
)}`
482
: requiresToken2 && !registrationToken
483
? "Enter the secret registration token"
484
: !email
485
? "How will you sign in?"
486
: !isValidEmailAddress(email)
487
? "Enter a valid email address above"
488
: requiredSSO != null
489
? "You must sign up via SSO"
490
: !password || password.length < MIN_PASSWORD_LENGTH
491
? `Choose password with at least ${MIN_PASSWORD_LENGTH} characters`
492
: password &&
493
password.length >= MIN_PASSWORD_LENGTH &&
494
passwordStrength.score <= MIN_PASSWORD_STRENGTH
495
? "Make your password more complex"
496
: !firstName?.trim()
497
? "Enter your first name above"
498
: !lastName?.trim()
499
? "Enter your last name above"
500
: signingUp
501
? ""
502
: "Sign Up!"}
503
{signingUp && (
504
<span style={{ marginLeft: "15px" }}>
505
<Loading>Signing Up...</Loading>
506
</span>
507
)}
508
</Button>
509
</div>
510
</AuthPageContainer>
511
);
512
}
513
514
interface EmailOrSSOProps {
515
email: string;
516
setEmail: (email: string) => void;
517
signUp: () => void;
518
strategies?: Strategy[];
519
hideSSO?: boolean;
520
}
521
522
function EmailOrSSO(props: EmailOrSSOProps) {
523
const { email, setEmail, signUp, strategies = [], hideSSO = false } = props;
524
const { emailSignup } = useCustomize();
525
526
function renderSSO() {
527
if (strategies.length == 0) return;
528
529
const emailStyle: CSSProperties = email
530
? { textAlign: "right", marginBottom: "20px" }
531
: {};
532
533
const style: CSSProperties = {
534
display: hideSSO ? "none" : "block",
535
...emailStyle,
536
};
537
538
return (
539
<div style={{ textAlign: "center", margin: "20px 0" }}>
540
<SSO size={email ? 24 : undefined} style={style} />
541
</div>
542
);
543
}
544
545
return (
546
<div>
547
<div>
548
<p style={{ color: "#444", marginTop: "10px" }}>
549
{hideSSO
550
? "Sign up using your single sign-on provider"
551
: strategies.length > 0 && emailSignup
552
? "Sign up using either your email address or a single sign-on provider."
553
: emailSignup
554
? "Enter the email address you will use to sign in."
555
: "Sign up using a single sign-on provider."}
556
</p>
557
</div>
558
{renderSSO()}
559
{emailSignup ? (
560
<p>
561
<p>Email address</p>
562
<Input
563
style={{ fontSize: "12pt" }}
564
placeholder="Email address"
565
autoComplete="username"
566
value={email}
567
onChange={(e) => setEmail(e.target.value)}
568
onPressEnter={signUp}
569
/>
570
</p>
571
) : undefined}
572
</div>
573
);
574
}
575
576
export function TermsCheckbox({
577
checked,
578
onChange,
579
style,
580
}: {
581
checked?: boolean;
582
onChange?: (boolean) => void;
583
style?: CSSProperties;
584
}) {
585
return (
586
<Checkbox
587
checked={checked}
588
style={style}
589
onChange={(e) => onChange?.(e.target.checked)}
590
>
591
I agree to the{" "}
592
<A external={true} href="/policies/terms">
593
Terms of Service
594
</A>
595
.
596
</Checkbox>
597
);
598
}
599
600
interface PasswordStrengthIndicatorProps {
601
score: number;
602
help?: string;
603
checking: boolean;
604
}
605
606
function PasswordStrengthIndicator({
607
score,
608
help,
609
checking,
610
}: PasswordStrengthIndicatorProps) {
611
if (checking) {
612
return (
613
<div style={{ fontSize: "12px", color: COLORS.GRAY_M }}>
614
Checking password strength...
615
</div>
616
);
617
}
618
619
const getStrengthColor = (score: number): string => {
620
switch (score) {
621
case 0:
622
case 1:
623
return COLORS.ANTD_RED_WARN;
624
case 2:
625
return COLORS.ORANGE_WARN;
626
case 3:
627
return COLORS.ANTD_YELL_M;
628
case 4:
629
return COLORS.BS_GREEN;
630
default:
631
return COLORS.GRAY_M;
632
}
633
};
634
635
const getStrengthLabel = (score: number): string => {
636
switch (score) {
637
case 0:
638
return "Very weak";
639
case 1:
640
return "Weak";
641
case 2:
642
return "Fair";
643
case 3:
644
return "Good";
645
case 4:
646
return "Strong";
647
default:
648
return "Unknown";
649
}
650
};
651
652
const getStrengthWidth = (score: number): string => {
653
return `${Math.max(10, (score + 1) * 20)}%`;
654
};
655
656
return (
657
<div style={{ fontSize: "12px" }}>
658
<div
659
style={{
660
display: "flex",
661
alignItems: "center",
662
marginBottom: "4px",
663
}}
664
>
665
<span style={{ marginRight: "8px", minWidth: "80px" }}>
666
Password strength:{" "}
667
</span>
668
<div
669
style={{
670
flex: 1,
671
height: "6px",
672
backgroundColor: COLORS.GRAY_LL,
673
borderRadius: "3px",
674
overflow: "hidden",
675
}}
676
>
677
<div
678
style={{
679
height: "100%",
680
width: getStrengthWidth(score),
681
backgroundColor: getStrengthColor(score),
682
transition: "width 0.3s ease, background-color 0.3s ease",
683
}}
684
/>
685
</div>
686
<span
687
style={{
688
marginLeft: "8px",
689
color: getStrengthColor(score),
690
fontWeight: "500",
691
minWidth: "60px",
692
}}
693
>
694
{getStrengthLabel(score)}
695
</span>
696
</div>
697
{help && (
698
<div
699
style={{
700
color: COLORS.GRAY_D,
701
fontSize: "11px",
702
marginTop: "2px",
703
}}
704
>
705
{help}
706
</div>
707
)}
708
</div>
709
);
710
}
711
712