Path: blob/master/src/packages/next/components/auth/sign-up.tsx
5928 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, useCallback, useEffect, useRef, useState } from "react";7import {8GoogleReCaptchaProvider,9useGoogleReCaptcha,10} from "react-google-recaptcha-v3";11import { debounce } from "lodash";1213import { reuseInFlight } from "@cocalc/util/reuse-in-flight";1415import Markdown from "@cocalc/frontend/editors/slate/static-markdown";16import {17MAX_PASSWORD_LENGTH,18MIN_PASSWORD_LENGTH,19MIN_PASSWORD_STRENGTH,20} from "@cocalc/util/auth";21import {22CONTACT_TAG,23CONTACT_THESE_TAGS,24} from "@cocalc/util/db-schema/accounts";25import {26is_valid_email_address as isValidEmailAddress,27len,28plural,29smallIntegerToEnglishWord,30} from "@cocalc/util/misc";31import { COLORS } from "@cocalc/util/theme";32import { Strategy } from "@cocalc/util/types/sso";33import { Paragraph } from "components/misc";34import A from "components/misc/A";35import Loading from "components/share/loading";36import apiPost from "lib/api/post";37import useCustomize from "lib/use-customize";38import AuthPageContainer from "./fragments/auth-page-container";39import SSO, { RequiredSSO, useRequiredSSO } from "./sso";40import Tags from "./tags";4142const LINE: CSSProperties = { margin: "15px 0" } as const;4344interface SignUpProps {45minimal?: boolean; // use a minimal interface with less explanation and instructions (e.g., for embedding in other pages)46requiresToken?: boolean; // will be determined by API call if not given.47onSuccess?: () => void; // if given, call after sign up *succeeds*.48has_site_license?: boolean;49publicPathId?: string;50showSignIn?: boolean;51signInAction?: () => void; // if given, replaces the default sign-in link behavior.52requireTags: boolean;53}5455export default function SignUp(props: SignUpProps) {56const { reCaptchaKey } = useCustomize();5758const body = <SignUp0 {...props} />;59if (reCaptchaKey == null) {60return body;61}6263return (64<GoogleReCaptchaProvider reCaptchaKey={reCaptchaKey}>65{body}66</GoogleReCaptchaProvider>67);68}6970function SignUp0({71requiresToken,72minimal,73onSuccess,74has_site_license,75publicPathId,76signInAction,77showSignIn,78requireTags,79}: SignUpProps) {80const {81anonymousSignup,82anonymousSignupLicensedShares,83siteName,84emailSignup,85accountCreationInstructions,86reCaptchaKey,87onCoCalcCom,88} = useCustomize();89const [tags, setTags] = useState<Set<string>>(new Set());90const [signupReason, setSignupReason] = useState<string>("");91const [email, setEmail] = useState<string>("");92const [registrationToken, setRegistrationToken] = useState<string>("");93const [password, setPassword] = useState<string>("");94const [firstName, setFirstName] = useState<string>("");95const [lastName, setLastName] = useState<string>("");96const [signingUp, setSigningUp] = useState<boolean>(false);97const [passwordStrength, setPasswordStrength] = useState<{98score: number;99help?: string;100}>({ score: 0 });101const [checkingPassword, setCheckingPassword] = useState<boolean>(false);102const [issues, setIssues] = useState<{103email?: string;104password?: string;105error?: string;106registrationToken?: string;107reCaptcha?: string;108}>({});109110const minTags = requireTags ? 1 : 0;111const showContact = CONTACT_THESE_TAGS.some((t) => tags.has(t));112const requestContact = tags.has(CONTACT_TAG) && showContact;113114const submittable = useRef<boolean>(false);115const { executeRecaptcha } = useGoogleReCaptcha();116const { strategies, supportVideoCall } = useCustomize();117118// Sometimes the user if this component knows requiresToken and sometimes they don't.119// If they don't, we have to make an API call to figure it out.120const [requiresToken2, setRequiresToken2] = useState<boolean | undefined>(121requiresToken,122);123124useEffect(() => {125if (requiresToken2 === undefined) {126(async () => {127try {128setRequiresToken2(await apiPost("/auth/requires-token"));129} catch (err) {}130})();131}132}, []);133134// Debounced password strength checking with reuse-in-flight protection135const debouncedCheckPassword = useCallback(136debounce((password: string) => {137checkPasswordStrengthReuseInFlight(password);138}, 100),139[],140);141142useEffect(() => {143if (!password) {144setPasswordStrength({ score: 0 });145return;146}147148debouncedCheckPassword(password);149}, [password, debouncedCheckPassword]);150151// based on email: if user has to sign up via SSO, this will tell which strategy to use.152const requiredSSO = useRequiredSSO(strategies, email);153154if (requiresToken2 === undefined || strategies == null) {155return <Loading />;156}157158// number of tags except for the one name "CONTACT_TAG"159const tagsSize = tags.size - (requestContact ? 1 : 0);160const needsTags = !minimal && onCoCalcCom && tagsSize < minTags;161const what = "role";162163submittable.current = !!(164requiredSSO == null &&165(!requiresToken2 || registrationToken) &&166email &&167isValidEmailAddress(email) &&168password &&169password.length >= MIN_PASSWORD_LENGTH &&170passwordStrength.score > MIN_PASSWORD_STRENGTH &&171firstName?.trim() &&172lastName?.trim() &&173!needsTags174);175176async function signUp() {177if (signingUp) return;178setIssues({});179try {180setSigningUp(true);181182let reCaptchaToken: undefined | string;183if (reCaptchaKey) {184if (!executeRecaptcha) {185throw Error("Please wait a few seconds, then try again.");186}187reCaptchaToken = await executeRecaptcha("signup");188}189190const result = await apiPost("/auth/sign-up", {191terms: true,192email,193password,194firstName,195lastName,196registrationToken,197reCaptchaToken,198publicPathId,199tags: Array.from(tags),200signupReason,201});202if (result.issues && len(result.issues) > 0) {203setIssues(result.issues);204} else {205onSuccess?.();206}207} catch (err) {208setIssues({ error: `${err}` });209} finally {210setSigningUp(false);211}212}213214async function checkPasswordStrength(password: string) {215if (!password || password.length < MIN_PASSWORD_LENGTH) {216setPasswordStrength({ score: 0 });217return;218}219220setCheckingPassword(true);221try {222const result = await apiPost("/auth/password-strength", { password });223setPasswordStrength(result);224} catch (err) {225// If the API fails, fall back to basic length check226setPasswordStrength({227score: password.length >= MIN_PASSWORD_LENGTH ? 1 : 0,228});229} finally {230setCheckingPassword(false);231}232}233234// Wrap the function to prevent concurrent calls235const checkPasswordStrengthReuseInFlight = reuseInFlight(236checkPasswordStrength,237);238239if (!emailSignup && strategies.length == 0) {240return (241<Alert242style={{ margin: "30px 15%" }}243type="error"244showIcon245message={"No Account Creation Allowed"}246description={247<div style={{ fontSize: "14pt", marginTop: "20px" }}>248<b>249There is no method enabled for creating an account on this server.250</b>251{(anonymousSignup ||252(anonymousSignupLicensedShares && has_site_license)) && (253<>254<br />255<br />256However, you can still{" "}257<A href="/auth/try">258try {siteName} without creating an account.259</A>260</>261)}262</div>263}264/>265);266}267268function renderFooter() {269return (270(!minimal || showSignIn) && (271<>272<div>273Already have an account?{" "}274{signInAction ? (275<a onClick={signInAction}>Sign In</a>276) : (277<A href="/auth/sign-in">Sign In</A>278)}{" "}279{anonymousSignup && (280<>281or{" "}282<A href="/auth/try">283{" "}284try {siteName} without creating an account.{" "}285</A>286</>287)}288</div>289</>290)291);292}293294function renderError() {295return (296issues.error && (297<Alert style={LINE} type="error" showIcon message={issues.error} />298)299);300}301302function renderSubtitle() {303return (304<>305<h4 style={{ color: COLORS.GRAY_M, marginBottom: "35px" }}>306Start collaborating for free today.307</h4>308{accountCreationInstructions && (309<Markdown value={accountCreationInstructions} />310)}311</>312);313}314315return (316<AuthPageContainer317error={renderError()}318footer={renderFooter()}319subtitle={renderSubtitle()}320minimal={minimal}321title={`Create a free account with ${siteName}`}322>323<Paragraph>324By creating an account, you agree to the{" "}325<A external={true} href="/policies/terms">326Terms of Service327</A>328.329</Paragraph>330{onCoCalcCom && supportVideoCall ? (331<Paragraph>332Do you need more information how {siteName} can be useful for you?{" "}333<A href={supportVideoCall}>Book a video call</A> and we'll help you334decide.335</Paragraph>336) : undefined}337<Divider />338{!minimal && onCoCalcCom ? (339<Tags340setTags={setTags}341signupReason={signupReason}342setSignupReason={setSignupReason}343tags={tags}344minTags={minTags}345what={what}346style={{ width: "880px", maxWidth: "100%", marginTop: "20px" }}347contact={showContact}348warning={needsTags}349/>350) : undefined}351<form>352{issues.reCaptcha ? (353<Alert354style={LINE}355type="error"356showIcon357message={issues.reCaptcha}358description={<>You may have to contact the site administrator.</>}359/>360) : undefined}361{issues.registrationToken && (362<Alert363style={LINE}364type="error"365showIcon366message={issues.registrationToken}367description={368<>369You may have to contact the site administrator for a370registration token.371</>372}373/>374)}375{requiresToken2 && (376<div style={LINE}>377<p>Registration Token</p>378<Input379style={{ fontSize: "12pt" }}380value={registrationToken}381placeholder="Enter your secret registration token"382onChange={(e) => setRegistrationToken(e.target.value)}383/>384</div>385)}386<EmailOrSSO387email={email}388setEmail={setEmail}389signUp={signUp}390strategies={strategies}391hideSSO={requiredSSO != null}392/>393<RequiredSSO strategy={requiredSSO} />394{issues.email && (395<Alert396style={LINE}397type="error"398showIcon399message={issues.email}400description={401<>402Choose a different email address,{" "}403<A href="/auth/sign-in">sign in</A>, or{" "}404<A href="/auth/password-reset">reset your password</A>.405</>406}407/>408)}409{requiredSSO == null && (410<div style={LINE}>411<p>Password</p>412<Input.Password413style={{ fontSize: "12pt" }}414value={password}415placeholder="Password"416autoComplete="new-password"417onChange={(e) => setPassword(e.target.value)}418onPressEnter={signUp}419maxLength={MAX_PASSWORD_LENGTH}420/>421{password && password.length >= MIN_PASSWORD_LENGTH && (422<div style={{ marginTop: "8px" }}>423<PasswordStrengthIndicator424score={passwordStrength.score}425help={passwordStrength.help}426checking={checkingPassword}427/>428</div>429)}430</div>431)}432{issues.password && (433<Alert style={LINE} type="error" showIcon message={issues.password} />434)}435{requiredSSO == null && (436<div style={LINE}>437<p>First name (Given name)</p>438<Input439style={{ fontSize: "12pt" }}440placeholder="First name"441value={firstName}442onChange={(e) => setFirstName(e.target.value)}443onPressEnter={signUp}444/>445</div>446)}447{requiredSSO == null && (448<div style={LINE}>449<p>Last name (Family name)</p>450<Input451style={{ fontSize: "12pt" }}452placeholder="Last name"453value={lastName}454onChange={(e) => setLastName(e.target.value)}455onPressEnter={signUp}456/>457</div>458)}459</form>460<div style={LINE}>461<Button462shape="round"463size="large"464disabled={!submittable.current || signingUp}465type="primary"466style={{467width: "100%",468marginTop: "15px",469color:470!submittable.current || signingUp471? COLORS.ANTD_RED_WARN472: undefined,473}}474onClick={signUp}475>476{needsTags && tagsSize < minTags477? `Select at least ${smallIntegerToEnglishWord(minTags)} ${plural(478minTags,479what,480)}`481: requiresToken2 && !registrationToken482? "Enter the secret registration token"483484? "How will you sign in?"485: !isValidEmailAddress(email)486? "Enter a valid email address above"487: requiredSSO != null488? "You must sign up via SSO"489: !password || password.length < MIN_PASSWORD_LENGTH490? `Choose password with at least ${MIN_PASSWORD_LENGTH} characters`491: password &&492password.length >= MIN_PASSWORD_LENGTH &&493passwordStrength.score <= MIN_PASSWORD_STRENGTH494? "Make your password more complex"495: !firstName?.trim()496? "Enter your first name above"497: !lastName?.trim()498? "Enter your last name above"499: signingUp500? ""501: "Sign Up!"}502{signingUp && (503<span style={{ marginLeft: "15px" }}>504<Loading>Signing Up...</Loading>505</span>506)}507</Button>508</div>509</AuthPageContainer>510);511}512513interface EmailOrSSOProps {514email: string;515setEmail: (email: string) => void;516signUp: () => void;517strategies?: Strategy[];518hideSSO?: boolean;519}520521function EmailOrSSO(props: EmailOrSSOProps) {522const { email, setEmail, signUp, strategies = [], hideSSO = false } = props;523const { emailSignup } = useCustomize();524525function renderSSO() {526if (strategies.length == 0) return;527528const emailStyle: CSSProperties = email529? { textAlign: "right", marginBottom: "20px" }530: {};531532const style: CSSProperties = {533display: hideSSO ? "none" : "block",534...emailStyle,535};536537return (538<div style={{ textAlign: "center", margin: "20px 0" }}>539<SSO size={email ? 24 : undefined} style={style} />540</div>541);542}543544return (545<div>546<div>547<p style={{ color: "#444", marginTop: "10px" }}>548{hideSSO549? "Sign up using your single sign-on provider"550: strategies.length > 0 && emailSignup551? "Sign up using either your email address or a single sign-on provider."552: emailSignup553? "Enter the email address you will use to sign in."554: "Sign up using a single sign-on provider."}555</p>556</div>557{renderSSO()}558{emailSignup ? (559<p>560<p>Email address</p>561<Input562style={{ fontSize: "12pt" }}563placeholder="Email address"564autoComplete="username"565value={email}566onChange={(e) => setEmail(e.target.value)}567onPressEnter={signUp}568/>569</p>570) : undefined}571</div>572);573}574575export function TermsCheckbox({576checked,577onChange,578style,579}: {580checked?: boolean;581onChange?: (boolean) => void;582style?: CSSProperties;583}) {584return (585<Checkbox586checked={checked}587style={style}588onChange={(e) => onChange?.(e.target.checked)}589>590I agree to the{" "}591<A external={true} href="/policies/terms">592Terms of Service593</A>594.595</Checkbox>596);597}598599interface PasswordStrengthIndicatorProps {600score: number;601help?: string;602checking: boolean;603}604605function PasswordStrengthIndicator({606score,607help,608checking,609}: PasswordStrengthIndicatorProps) {610if (checking) {611return (612<div style={{ fontSize: "12px", color: COLORS.GRAY_M }}>613Checking password strength...614</div>615);616}617618const getStrengthColor = (score: number): string => {619switch (score) {620case 0:621case 1:622return COLORS.ANTD_RED_WARN;623case 2:624return COLORS.ORANGE_WARN;625case 3:626return COLORS.ANTD_YELL_M;627case 4:628return COLORS.BS_GREEN;629default:630return COLORS.GRAY_M;631}632};633634const getStrengthLabel = (score: number): string => {635switch (score) {636case 0:637return "Very weak";638case 1:639return "Weak";640case 2:641return "Fair";642case 3:643return "Good";644case 4:645return "Strong";646default:647return "Unknown";648}649};650651const getStrengthWidth = (score: number): string => {652return `${Math.max(10, (score + 1) * 20)}%`;653};654655return (656<div style={{ fontSize: "12px" }}>657<div658style={{659display: "flex",660alignItems: "center",661marginBottom: "4px",662}}663>664<span style={{ marginRight: "8px", minWidth: "80px" }}>665Password strength:{" "}666</span>667<div668style={{669flex: 1,670height: "6px",671backgroundColor: COLORS.GRAY_LL,672borderRadius: "3px",673overflow: "hidden",674}}675>676<div677style={{678height: "100%",679width: getStrengthWidth(score),680backgroundColor: getStrengthColor(score),681transition: "width 0.3s ease, background-color 0.3s ease",682}}683/>684</div>685<span686style={{687marginLeft: "8px",688color: getStrengthColor(score),689fontWeight: "500",690minWidth: "60px",691}}692>693{getStrengthLabel(score)}694</span>695</div>696{help && (697<div698style={{699color: COLORS.GRAY_D,700fontSize: "11px",701marginTop: "2px",702}}703>704{help}705</div>706)}707</div>708);709}710711712