// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ChangeEvent } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { Plugin } from 'intl-tel-input'; import intlTelInput from 'intl-tel-input'; import { strictAssert } from '../util/assert'; import { parseNumber } from '../util/libphonenumberUtil'; import { missingCaseError } from '../util/missingCaseError'; import { VerificationTransport } from '../types/VerificationTransport'; function PhoneInput({ initialValue, onValidation, onNumberChange, }: { initialValue: string | undefined; onValidation: (isValid: boolean) => void; onNumberChange: (number?: string) => void; }): JSX.Element { const [isValid, setIsValid] = useState(false); const pluginRef = useRef<Plugin | undefined>(); const elemRef = useRef<HTMLInputElement | null>(null); const onRef = useCallback( (elem: HTMLInputElement | null) => { elemRef.current = elem; if (!elem) { return; } if (initialValue !== undefined) { // eslint-disable-next-line no-param-reassign elem.value = initialValue; } pluginRef.current?.destroy(); const plugin = intlTelInput(elem); pluginRef.current = plugin; }, [initialValue] ); const validateNumber = useCallback( (number: string) => { const { current: plugin } = pluginRef; if (!plugin) { return; } const regionCode = plugin.getSelectedCountryData().iso2; const parsedNumber = parseNumber(number, regionCode); setIsValid(parsedNumber.isValidNumber); onValidation(parsedNumber.isValidNumber); onNumberChange( parsedNumber.isValidNumber ? parsedNumber.e164 : undefined ); }, [setIsValid, onNumberChange, onValidation] ); const onChange = useCallback( (_: ChangeEvent<HTMLInputElement>) => { if (elemRef.current) { validateNumber(elemRef.current.value); } }, [validateNumber] ); const onKeyDown = useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { // Pacify TypeScript and handle events bubbling up if (event.target instanceof HTMLInputElement) { validateNumber(event.target.value); } }, [validateNumber] ); return ( <div className="phone-input"> <div className="phone-input-form"> <div className={`number-container ${isValid ? 'valid' : 'invalid'}`}> <input className="number" type="tel" ref={onRef} onChange={onChange} onKeyDown={onKeyDown} placeholder="Phone Number" /> </div> </div> </div> ); } enum Stage { PhoneNumber, VerificationCode, ProfileName, } type StageData = | { stage: Stage.PhoneNumber; initialNumber: string | undefined; } | { stage: Stage.VerificationCode; number: string; sessionId: string; } | { stage: Stage.ProfileName; }; function PhoneNumberStage({ initialNumber, getCaptchaToken, requestVerification, onNext, }: { initialNumber: string | undefined; getCaptchaToken: () => Promise<string>; requestVerification: ( number: string, captcha: string, transport: VerificationTransport ) => Promise<{ sessionId: string }>; onNext: (result: { number: string; sessionId: string }) => void; }): JSX.Element { const [number, setNumber] = useState<string | undefined>(initialNumber); const [isValidNumber, setIsValidNumber] = useState(false); const [error, setError] = useState<string | undefined>(undefined); const onRequestCode = useCallback( async (transport: VerificationTransport) => { if (!isValidNumber) { return; } if (!number) { setIsValidNumber(false); setError(undefined); return; } try { const token = await getCaptchaToken(); const result = await requestVerification(number, token, transport); setError(undefined); onNext({ number, sessionId: result.sessionId }); } catch (err) { setError(err.message); } }, [ getCaptchaToken, isValidNumber, setIsValidNumber, setError, requestVerification, number, onNext, ] ); const onSMSClick = useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); e.stopPropagation(); void onRequestCode(VerificationTransport.SMS); }, [onRequestCode] ); const onVoiceClick = useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); e.stopPropagation(); void onRequestCode(VerificationTransport.Voice); }, [onRequestCode] ); return ( <div className="step-body"> <div className="banner-image module-splash-screen__logo module-img--128" /> <div className="header">Create your Signal Account</div> <div> <div className="phone-input-form"> <PhoneInput initialValue={initialNumber} onValidation={setIsValidNumber} onNumberChange={setNumber} /> </div> </div> <div className="StandaloneRegistration__error">{error}</div> <div className="clearfix"> <button type="button" className="button" disabled={!isValidNumber} onClick={onSMSClick} > Send SMS </button> <button type="button" className="link" tabIndex={-1} disabled={!isValidNumber} onClick={onVoiceClick} > Call </button> </div> </div> ); } export function VerificationCodeStage({ number, sessionId, registerSingleDevice, onNext, onBack, }: { number: string; sessionId: string; registerSingleDevice: ( number: string, code: string, sessionId: string ) => Promise<void>; onNext: () => void; onBack: () => void; }): JSX.Element { const [code, setCode] = useState(''); const [isValidCode, setIsValidCode] = useState(false); const [error, setError] = useState<string | undefined>(undefined); const onChangeCode = useCallback( (event: ChangeEvent<HTMLInputElement>) => { const { value } = event.target; setIsValidCode(value.length === 6); setCode(value); }, [setIsValidCode, setCode] ); const onBackClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { event.preventDefault(); event.stopPropagation(); onBack(); }, [onBack] ); const onVerifyCode = useCallback( async (event: React.MouseEvent<HTMLButtonElement>) => { event.preventDefault(); event.stopPropagation(); if (!isValidCode || !sessionId) { return; } strictAssert(number != null && code.length > 0, 'Missing number or code'); try { await registerSingleDevice(number, code, sessionId); onNext(); } catch (err) { setError(err.message); } }, [ registerSingleDevice, onNext, number, code, sessionId, setError, isValidCode, ] ); return ( <> <div className="step-body"> <div className="banner-image module-splash-screen__logo module-img--128" /> <div className="header">Create your Signal Account</div> <input className={`form-control ${isValidCode ? 'valid' : 'invalid'}`} type="text" dir="auto" pattern="\s*[0-9]{3}-?[0-9]{3}\s*" title="Enter your 6-digit verification code. If you did not receive a code, click Call or Send SMS to request a new one" placeholder="Verification Code" autoComplete="off" value={code} onChange={onChangeCode} /> <div className="StandaloneRegistration__error">{error}</div> </div> <div className="nav"> <button type="button" className="button" onClick={onBackClick}> Back </button> <button type="button" className="button" disabled={!isValidCode} onClick={onVerifyCode} > Register </button> </div> </> ); } export function ProfileNameStage({ uploadProfile, onNext, }: { uploadProfile: (opts: { firstName: string; lastName: string; }) => Promise<void>; onNext: () => void; }): JSX.Element { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [error, setError] = useState<string | undefined>(undefined); const onChangeFirstName = useCallback( (event: ChangeEvent<HTMLInputElement>) => setFirstName(event.target.value), [] ); const onChangeLastName = useCallback( (event: ChangeEvent<HTMLInputElement>) => setLastName(event.target.value), [] ); const onNextClick = useCallback( async (event: React.MouseEvent<HTMLButtonElement>) => { event.preventDefault(); event.stopPropagation(); try { await uploadProfile({ firstName, lastName }); onNext(); } catch (err) { setError(err.message); } }, [onNext, firstName, lastName, uploadProfile] ); return ( <> <div className="step-body"> <div className="banner-image module-splash-screen__logo module-img--128" /> <div className="header">Select Profile Name</div> <input className={`form-control ${firstName ? 'valid' : 'invalid'}`} type="text" dir="auto" pattern="\s*[0-9]{3}-?[0-9]{3}\s*" title="Enter your first name" placeholder="First Name (Required)" autoComplete="off" value={firstName} onChange={onChangeFirstName} /> <input className="form-control" type="text" dir="auto" pattern="\s*[0-9]{3}-?[0-9]{3}\s*" title="Enter your last name" placeholder="Last Name (Optional)" autoComplete="off" value={lastName} onChange={onChangeLastName} /> {/* TODO(indutny): highlight error */} <div>{error}</div> </div> <div className="nav"> <button type="button" className="button" disabled={!firstName} onClick={onNextClick} > Finish </button> </div> </> ); } export type PropsType = Readonly<{ onComplete: () => void; getCaptchaToken: () => Promise<string>; requestVerification: ( number: string, captcha: string, transport: VerificationTransport ) => Promise<{ sessionId: string }>; registerSingleDevice: ( number: string, code: string, sessionId: string ) => Promise<void>; uploadProfile: (opts: { firstName: string; lastName: string; }) => Promise<void>; readyForUpdates: () => void; }>; export function StandaloneRegistration({ onComplete, getCaptchaToken, requestVerification, registerSingleDevice, uploadProfile, readyForUpdates, }: PropsType): JSX.Element { useEffect(() => { readyForUpdates(); }, [readyForUpdates]); const [stageData, setStageData] = useState<StageData>({ stage: Stage.PhoneNumber, initialNumber: undefined, }); const onPhoneNumber = useCallback( ({ number, sessionId }: { number: string; sessionId: string }) => { setStageData({ stage: Stage.VerificationCode, number, sessionId, }); }, [] ); const onBackToPhoneNumber = useCallback(() => { setStageData(data => { if (data.stage !== Stage.VerificationCode) { return data; } return { stage: Stage.PhoneNumber, initialNumber: data.number, }; }); }, []); const onRegistered = useCallback(() => { setStageData({ stage: Stage.ProfileName, }); }, []); let body: JSX.Element; if (stageData.stage === Stage.PhoneNumber) { body = ( <PhoneNumberStage {...stageData} getCaptchaToken={getCaptchaToken} requestVerification={requestVerification} onNext={onPhoneNumber} /> ); } else if (stageData.stage === Stage.VerificationCode) { body = ( <VerificationCodeStage {...stageData} registerSingleDevice={registerSingleDevice} onNext={onRegistered} onBack={onBackToPhoneNumber} /> ); } else if (stageData.stage === Stage.ProfileName) { body = ( <ProfileNameStage {...stageData} uploadProfile={uploadProfile} onNext={onComplete} /> ); } else { throw missingCaseError(stageData); } return ( <div className="full-screen-flow"> <div className="module-title-bar-drag-area" /> <div className="step"> <div className="inner">{body}</div> </div> </div> ); }