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
1492 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, useEffect, useRef, useState } from "react";
8
import {
9
GoogleReCaptchaProvider,
10
useGoogleReCaptcha,
11
} from "react-google-recaptcha-v3";
12
import Markdown from "@cocalc/frontend/editors/slate/static-markdown";
13
import { MAX_PASSWORD_LENGTH } from "@cocalc/util/auth";
14
import {
15
CONTACT_TAG,
16
CONTACT_THESE_TAGS,
17
} from "@cocalc/util/db-schema/accounts";
18
import {
19
is_valid_email_address as isValidEmailAddress,
20
len,
21
plural,
22
smallIntegerToEnglishWord,
23
} from "@cocalc/util/misc";
24
import { COLORS } from "@cocalc/util/theme";
25
import { Strategy } from "@cocalc/util/types/sso";
26
import { Paragraph } from "components/misc";
27
import A from "components/misc/A";
28
import Loading from "components/share/loading";
29
import apiPost from "lib/api/post";
30
import useCustomize from "lib/use-customize";
31
import AuthPageContainer from "./fragments/auth-page-container";
32
import SSO, { RequiredSSO, useRequiredSSO } from "./sso";
33
import Tags from "./tags";
34
35
const LINE: CSSProperties = { margin: "15px 0" } as const;
36
37
interface SignUpProps {
38
minimal?: boolean; // use a minimal interface with less explanation and instructions (e.g., for embedding in other pages)
39
requiresToken?: boolean; // will be determined by API call if not given.
40
onSuccess?: () => void; // if given, call after sign up *succeeds*.
41
has_site_license?: boolean;
42
publicPathId?: string;
43
showSignIn?: boolean;
44
signInAction?: () => void; // if given, replaces the default sign-in link behavior.
45
requireTags: boolean;
46
}
47
48
export default function SignUp(props: SignUpProps) {
49
const { reCaptchaKey } = useCustomize();
50
51
const body = <SignUp0 {...props} />;
52
if (reCaptchaKey == null) {
53
return body;
54
}
55
56
return (
57
<GoogleReCaptchaProvider reCaptchaKey={reCaptchaKey}>
58
{body}
59
</GoogleReCaptchaProvider>
60
);
61
}
62
63
function SignUp0({
64
requiresToken,
65
minimal,
66
onSuccess,
67
has_site_license,
68
publicPathId,
69
signInAction,
70
showSignIn,
71
requireTags,
72
}: SignUpProps) {
73
const {
74
anonymousSignup,
75
anonymousSignupLicensedShares,
76
siteName,
77
emailSignup,
78
accountCreationInstructions,
79
reCaptchaKey,
80
onCoCalcCom,
81
} = useCustomize();
82
const [tags, setTags] = useState<Set<string>>(new Set());
83
const [signupReason, setSignupReason] = useState<string>("");
84
const [email, setEmail] = useState<string>("");
85
const [registrationToken, setRegistrationToken] = useState<string>("");
86
const [password, setPassword] = useState<string>("");
87
const [firstName, setFirstName] = useState<string>("");
88
const [lastName, setLastName] = useState<string>("");
89
const [signingUp, setSigningUp] = useState<boolean>(false);
90
const [issues, setIssues] = useState<{
91
email?: string;
92
password?: string;
93
error?: string;
94
registrationToken?: string;
95
reCaptcha?: string;
96
}>({});
97
98
const minTags = requireTags ? 1 : 0;
99
const showContact = CONTACT_THESE_TAGS.some((t) => tags.has(t));
100
const requestContact = tags.has(CONTACT_TAG) && showContact;
101
102
const submittable = useRef<boolean>(false);
103
const { executeRecaptcha } = useGoogleReCaptcha();
104
const { strategies, supportVideoCall } = useCustomize();
105
106
// Sometimes the user if this component knows requiresToken and sometimes they don't.
107
// If they don't, we have to make an API call to figure it out.
108
const [requiresToken2, setRequiresToken2] = useState<boolean | undefined>(
109
requiresToken,
110
);
111
112
useEffect(() => {
113
if (requiresToken2 === undefined) {
114
(async () => {
115
try {
116
setRequiresToken2(await apiPost("/auth/requires-token"));
117
} catch (err) {}
118
})();
119
}
120
}, []);
121
122
// based on email: if user has to sign up via SSO, this will tell which strategy to use.
123
const requiredSSO = useRequiredSSO(strategies, email);
124
125
if (requiresToken2 === undefined || strategies == null) {
126
return <Loading />;
127
}
128
129
// number of tags except for the one name "CONTACT_TAG"
130
const tagsSize = tags.size - (requestContact ? 1 : 0);
131
const needsTags = !minimal && onCoCalcCom && tagsSize < minTags;
132
const what = "role";
133
134
submittable.current = !!(
135
requiredSSO == null &&
136
(!requiresToken2 || registrationToken) &&
137
email &&
138
isValidEmailAddress(email) &&
139
password &&
140
password.length >= 6 &&
141
firstName?.trim() &&
142
lastName?.trim() &&
143
!needsTags
144
);
145
146
async function signUp() {
147
if (signingUp) return;
148
setIssues({});
149
try {
150
setSigningUp(true);
151
152
let reCaptchaToken: undefined | string;
153
if (reCaptchaKey) {
154
if (!executeRecaptcha) {
155
throw Error("Please wait a few seconds, then try again.");
156
}
157
reCaptchaToken = await executeRecaptcha("signup");
158
}
159
160
const result = await apiPost("/auth/sign-up", {
161
terms: true,
162
email,
163
password,
164
firstName,
165
lastName,
166
registrationToken,
167
reCaptchaToken,
168
publicPathId,
169
tags: Array.from(tags),
170
signupReason,
171
});
172
if (result.issues && len(result.issues) > 0) {
173
setIssues(result.issues);
174
} else {
175
onSuccess?.();
176
}
177
} catch (err) {
178
setIssues({ error: `${err}` });
179
} finally {
180
setSigningUp(false);
181
}
182
}
183
184
if (!emailSignup && strategies.length == 0) {
185
return (
186
<Alert
187
style={{ margin: "30px 15%" }}
188
type="error"
189
showIcon
190
message={"No Account Creation Allowed"}
191
description={
192
<div style={{ fontSize: "14pt", marginTop: "20px" }}>
193
<b>
194
There is no method enabled for creating an account on this server.
195
</b>
196
{(anonymousSignup ||
197
(anonymousSignupLicensedShares && has_site_license)) && (
198
<>
199
<br />
200
<br />
201
However, you can still{" "}
202
<A href="/auth/try">
203
try {siteName} without creating an account.
204
</A>
205
</>
206
)}
207
</div>
208
}
209
/>
210
);
211
}
212
213
function renderFooter() {
214
return (
215
(!minimal || showSignIn) && (
216
<>
217
<div>
218
Already have an account?{" "}
219
{signInAction ? (
220
<a onClick={signInAction}>Sign In</a>
221
) : (
222
<A href="/auth/sign-in">Sign In</A>
223
)}{" "}
224
{anonymousSignup && (
225
<>
226
or{" "}
227
<A href="/auth/try">
228
{" "}
229
try {siteName} without creating an account.{" "}
230
</A>
231
</>
232
)}
233
</div>
234
</>
235
)
236
);
237
}
238
239
function renderError() {
240
return (
241
issues.error && (
242
<Alert style={LINE} type="error" showIcon message={issues.error} />
243
)
244
);
245
}
246
247
function renderSubtitle() {
248
return (
249
<>
250
<h4 style={{ color: COLORS.GRAY_M, marginBottom: "35px" }}>
251
Start collaborating for free today.
252
</h4>
253
{accountCreationInstructions && (
254
<Markdown value={accountCreationInstructions} />
255
)}
256
</>
257
);
258
}
259
260
return (
261
<AuthPageContainer
262
error={renderError()}
263
footer={renderFooter()}
264
subtitle={renderSubtitle()}
265
minimal={minimal}
266
title={`Create a free account with ${siteName}`}
267
>
268
<Paragraph>
269
By creating an account, you agree to the{" "}
270
<A external={true} href="/policies/terms">
271
Terms of Service
272
</A>
273
.
274
</Paragraph>
275
{onCoCalcCom && supportVideoCall ? (
276
<Paragraph>
277
Do you need more information how {siteName} can be useful for you?{" "}
278
<A href={supportVideoCall}>Book a video call</A> and we'll help you
279
decide.
280
</Paragraph>
281
) : undefined}
282
<Divider />
283
{!minimal && onCoCalcCom ? (
284
<Tags
285
setTags={setTags}
286
signupReason={signupReason}
287
setSignupReason={setSignupReason}
288
tags={tags}
289
minTags={minTags}
290
what={what}
291
style={{ width: "880px", maxWidth: "100%", marginTop: "20px" }}
292
contact={showContact}
293
warning={needsTags}
294
/>
295
) : undefined}
296
<form>
297
{issues.reCaptcha ? (
298
<Alert
299
style={LINE}
300
type="error"
301
showIcon
302
message={issues.reCaptcha}
303
description={<>You may have to contact the site administrator.</>}
304
/>
305
) : undefined}
306
{issues.registrationToken && (
307
<Alert
308
style={LINE}
309
type="error"
310
showIcon
311
message={issues.registrationToken}
312
description={
313
<>
314
You may have to contact the site administrator for a
315
registration token.
316
</>
317
}
318
/>
319
)}
320
{requiresToken2 && (
321
<div style={LINE}>
322
<p>Registration Token</p>
323
<Input
324
style={{ fontSize: "12pt" }}
325
value={registrationToken}
326
placeholder="Enter your secret registration token"
327
onChange={(e) => setRegistrationToken(e.target.value)}
328
/>
329
</div>
330
)}
331
<EmailOrSSO
332
email={email}
333
setEmail={setEmail}
334
signUp={signUp}
335
strategies={strategies}
336
hideSSO={requiredSSO != null}
337
/>
338
<RequiredSSO strategy={requiredSSO} />
339
{issues.email && (
340
<Alert
341
style={LINE}
342
type="error"
343
showIcon
344
message={issues.email}
345
description={
346
<>
347
Choose a different email address,{" "}
348
<A href="/auth/sign-in">sign in</A>, or{" "}
349
<A href="/auth/password-reset">reset your password</A>.
350
</>
351
}
352
/>
353
)}
354
{requiredSSO == null && (
355
<div style={LINE}>
356
<p>Password</p>
357
<Input.Password
358
style={{ fontSize: "12pt" }}
359
value={password}
360
placeholder="Password"
361
autoComplete="new-password"
362
onChange={(e) => setPassword(e.target.value)}
363
onPressEnter={signUp}
364
maxLength={MAX_PASSWORD_LENGTH}
365
/>
366
</div>
367
)}
368
{issues.password && (
369
<Alert style={LINE} type="error" showIcon message={issues.password} />
370
)}
371
{requiredSSO == null && (
372
<div style={LINE}>
373
<p>First name (Given name)</p>
374
<Input
375
style={{ fontSize: "12pt" }}
376
placeholder="First name"
377
value={firstName}
378
onChange={(e) => setFirstName(e.target.value)}
379
onPressEnter={signUp}
380
/>
381
</div>
382
)}
383
{requiredSSO == null && (
384
<div style={LINE}>
385
<p>Last name (Family name)</p>
386
<Input
387
style={{ fontSize: "12pt" }}
388
placeholder="Last name"
389
value={lastName}
390
onChange={(e) => setLastName(e.target.value)}
391
onPressEnter={signUp}
392
/>
393
</div>
394
)}
395
</form>
396
<div style={LINE}>
397
<Button
398
shape="round"
399
size="large"
400
disabled={!submittable.current || signingUp}
401
type="primary"
402
style={{
403
width: "100%",
404
marginTop: "15px",
405
color:
406
!submittable.current || signingUp
407
? COLORS.ANTD_RED_WARN
408
: undefined,
409
}}
410
onClick={signUp}
411
>
412
{needsTags && tagsSize < minTags
413
? `Select at least ${smallIntegerToEnglishWord(minTags)} ${plural(
414
minTags,
415
what,
416
)}`
417
: requiresToken2 && !registrationToken
418
? "Enter the secret registration token"
419
: !email
420
? "How will you sign in?"
421
: !isValidEmailAddress(email)
422
? "Enter a valid email address above"
423
: requiredSSO != null
424
? "You must sign up via SSO"
425
: !password || password.length < 6
426
? "Choose password with at least 6 characters"
427
: !firstName?.trim()
428
? "Enter your first name above"
429
: !lastName?.trim()
430
? "Enter your last name above"
431
: signingUp
432
? ""
433
: "Sign Up!"}
434
{signingUp && (
435
<span style={{ marginLeft: "15px" }}>
436
<Loading>Signing Up...</Loading>
437
</span>
438
)}
439
</Button>
440
</div>
441
</AuthPageContainer>
442
);
443
}
444
445
interface EmailOrSSOProps {
446
email: string;
447
setEmail: (email: string) => void;
448
signUp: () => void;
449
strategies?: Strategy[];
450
hideSSO?: boolean;
451
}
452
453
function EmailOrSSO(props: EmailOrSSOProps) {
454
const { email, setEmail, signUp, strategies = [], hideSSO = false } = props;
455
const { emailSignup } = useCustomize();
456
457
function renderSSO() {
458
if (strategies.length == 0) return;
459
460
const emailStyle: CSSProperties = email
461
? { textAlign: "right", marginBottom: "20px" }
462
: {};
463
464
const style: CSSProperties = {
465
display: hideSSO ? "none" : "block",
466
...emailStyle,
467
};
468
469
return (
470
<div style={{ textAlign: "center", margin: "20px 0" }}>
471
<SSO size={email ? 24 : undefined} style={style} />
472
</div>
473
);
474
}
475
476
return (
477
<div>
478
<div>
479
<p style={{ color: "#444", marginTop: "10px" }}>
480
{hideSSO
481
? "Sign up using your single sign-on provider"
482
: strategies.length > 0 && emailSignup
483
? "Sign up using either your email address or a single sign-on provider."
484
: emailSignup
485
? "Enter the email address you will use to sign in."
486
: "Sign up using a single sign-on provider."}
487
</p>
488
</div>
489
{renderSSO()}
490
{emailSignup ? (
491
<p>
492
<p>Email address</p>
493
<Input
494
style={{ fontSize: "12pt" }}
495
placeholder="Email address"
496
autoComplete="username"
497
value={email}
498
onChange={(e) => setEmail(e.target.value)}
499
onPressEnter={signUp}
500
/>
501
</p>
502
) : undefined}
503
</div>
504
);
505
}
506
507
export function TermsCheckbox({
508
checked,
509
onChange,
510
style,
511
}: {
512
checked?: boolean;
513
onChange?: (boolean) => void;
514
style?: CSSProperties;
515
}) {
516
return (
517
<Checkbox
518
checked={checked}
519
style={style}
520
onChange={(e) => onChange?.(e.target.checked)}
521
>
522
I agree to the{" "}
523
<A external={true} href="/policies/terms">
524
Terms of Service
525
</A>
526
.
527
</Checkbox>
528
);
529
}
530
531