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