From 12b57601ac3b52a424ab83c55b5dcb386b6589a2 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 15 Jul 2024 14:05:09 -0700 Subject: [PATCH] Accept profile name during standalone registration --- stylesheets/_global.scss | 4 + ts/components/App.tsx | 12 + .../StandaloneRegistration.stories.tsx | 40 ++ ts/components/StandaloneRegistration.tsx | 455 ++++++++++++++---- ts/state/smart/App.tsx | 39 ++ 5 files changed, 447 insertions(+), 103 deletions(-) create mode 100644 ts/components/StandaloneRegistration.stories.tsx diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index fa6535f3b..4f6f5943e 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -631,6 +631,10 @@ $loading-height: 16px; float: inline-start; } } + + .StandaloneRegistration__error { + color: $color-accent-red; + } } //yellow border fix diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 5c0382b95..286133267 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -17,14 +17,20 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; type PropsType = { appView: AppViewType; openInbox: () => void; + getCaptchaToken: () => Promise; registerSingleDevice: ( number: string, code: string, sessionId: string ) => Promise; + uploadProfile: (opts: { + firstName: string; + lastName: string; + }) => Promise; renderCallManager: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element; hasSelectedStoryData: boolean; + readyForUpdates: () => void; renderStoryViewer: (closeView: () => unknown) => JSX.Element; renderLightbox: () => JSX.Element | null; requestVerification: ( @@ -44,11 +50,13 @@ type PropsType = { export function App({ appView, + getCaptchaToken, hasSelectedStoryData, isFullScreen, isMaximized, openInbox, osClassName, + readyForUpdates, registerSingleDevice, renderCallManager, renderGlobalModalContainer, @@ -57,6 +65,7 @@ export function App({ renderStoryViewer, requestVerification, theme, + uploadProfile, viewStory, }: PropsType): JSX.Element { let contents; @@ -71,8 +80,11 @@ export function App({ contents = ( ); } else if (appView === AppViewType.Inbox) { diff --git a/ts/components/StandaloneRegistration.stories.tsx b/ts/components/StandaloneRegistration.stories.tsx new file mode 100644 index 000000000..ce4000aed --- /dev/null +++ b/ts/components/StandaloneRegistration.stories.tsx @@ -0,0 +1,40 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { StandaloneRegistration } from './StandaloneRegistration'; +import type { PropsType } from './StandaloneRegistration'; +import { SECOND } from '../util/durations'; +import { sleep } from '../util/sleep'; + +export default { + title: 'Components/StandaloneRegistration', + args: { + getCaptchaToken: fn(async () => { + await sleep(SECOND); + return 'captcha-token'; + }), + requestVerification: fn(async () => { + await sleep(SECOND); + return { sessionId: 'fake-session-id' }; + }), + registerSingleDevice: fn(async () => { + await sleep(SECOND); + }), + uploadProfile: fn(async () => { + await sleep(SECOND); + }), + onComplete: fn(), + readyForUpdates: fn(), + }, +} satisfies Meta; + +// eslint-disable-next-line react/function-component-definition +const Template: StoryFn = args => { + return ; +}; + +export const Default = Template.bind({}); diff --git a/ts/components/StandaloneRegistration.tsx b/ts/components/StandaloneRegistration.tsx index 6253cba3d..74ba8cc6b 100644 --- a/ts/components/StandaloneRegistration.tsx +++ b/ts/components/StandaloneRegistration.tsx @@ -7,15 +7,16 @@ import type { Plugin } from 'intl-tel-input'; import intlTelInput from 'intl-tel-input'; import { strictAssert } from '../util/assert'; -import * as log from '../logging/log'; import { parseNumber } from '../util/libphonenumberUtil'; -import { getChallengeURL } from '../challenge'; +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 { @@ -23,18 +24,26 @@ function PhoneInput({ const pluginRef = useRef(); const elemRef = useRef(null); - const onRef = useCallback((elem: HTMLInputElement | null) => { - elemRef.current = elem; + const onRef = useCallback( + (elem: HTMLInputElement | null) => { + elemRef.current = elem; - if (!elem) { - return; - } + if (!elem) { + return; + } - pluginRef.current?.destroy(); + if (initialValue !== undefined) { + // eslint-disable-next-line no-param-reassign + elem.value = initialValue; + } - const plugin = intlTelInput(elem); - pluginRef.current = plugin; - }, []); + pluginRef.current?.destroy(); + + const plugin = intlTelInput(elem); + pluginRef.current = plugin; + }, + [initialValue] + ); const validateNumber = useCallback( (number: string) => { @@ -94,34 +103,45 @@ function PhoneInput({ ); } -export function StandaloneRegistration({ - onComplete, +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, - registerSingleDevice, + onNext, }: { - onComplete: () => void; + initialNumber: string | undefined; + getCaptchaToken: () => Promise; requestVerification: ( number: string, captcha: string, transport: VerificationTransport ) => Promise<{ sessionId: string }>; - registerSingleDevice: ( - number: string, - code: string, - sessionId: string - ) => Promise; + onNext: (result: { number: string; sessionId: string }) => void; }): JSX.Element { - useEffect(() => { - window.IPC.readyForUpdates(); - }, []); + const [number, setNumber] = useState(initialNumber); const [isValidNumber, setIsValidNumber] = useState(false); - const [isValidCode, setIsValidCode] = useState(false); - const [number, setNumber] = useState(undefined); - const [code, setCode] = useState(''); const [error, setError] = useState(undefined); - const [sessionId, setSessionId] = useState(undefined); - const [status, setStatus] = useState(undefined); const onRequestCode = useCallback( async (transport: VerificationTransport) => { @@ -135,26 +155,25 @@ export function StandaloneRegistration({ return; } - const url = getChallengeURL('registration'); - log.info(`StandaloneRegistration: navigating to ${url}`); - document.location.href = url; - if (!window.Signal.challengeHandler) { - setError('Captcha handler is not ready!'); - return; - } - const token = await window.Signal.challengeHandler.requestCaptcha({ - reason: 'standalone registration', - }); - try { + const token = await getCaptchaToken(); const result = await requestVerification(number, token, transport); - setSessionId(result.sessionId); setError(undefined); + + onNext({ number, sessionId: result.sessionId }); } catch (err) { setError(err.message); } }, - [isValidNumber, setIsValidNumber, setError, requestVerification, number] + [ + getCaptchaToken, + isValidNumber, + setIsValidNumber, + setError, + requestVerification, + number, + onNext, + ] ); const onSMSClick = useCallback( @@ -177,6 +196,65 @@ export function StandaloneRegistration({ [onRequestCode] ); + return ( +
+
+
Create your Signal Account
+ +
+
+ +
+
+
{error}
+
+ + +
+
+ ); +} + +export function VerificationCodeStage({ + number, + sessionId, + registerSingleDevice, + onNext, + onBack, +}: { + number: string; + sessionId: string; + registerSingleDevice: ( + number: string, + code: string, + sessionId: string + ) => Promise; + onNext: () => void; + onBack: () => void; +}): JSX.Element { + const [code, setCode] = useState(''); + const [isValidCode, setIsValidCode] = useState(false); + const [error, setError] = useState(undefined); + const onChangeCode = useCallback( (event: ChangeEvent) => { const { value } = event.target; @@ -187,12 +265,21 @@ export function StandaloneRegistration({ [setIsValidCode, setCode] ); + const onBackClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onBack(); + }, + [onBack] + ); + const onVerifyCode = useCallback( async (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - if (!isValidNumber || !isValidCode || !sessionId) { + if (!isValidCode || !sessionId) { return; } @@ -200,84 +287,246 @@ export function StandaloneRegistration({ try { await registerSingleDevice(number, code, sessionId); - onComplete(); + onNext(); } catch (err) { - setStatus(err.message); + setError(err.message); } }, [ registerSingleDevice, - onComplete, + onNext, number, code, sessionId, - setStatus, - isValidNumber, + setError, isValidCode, ] ); + return ( + <> +
+
+
Create your Signal Account
+ + +
{error}
+
+
+ + +
+ + ); +} + +export function ProfileNameStage({ + uploadProfile, + onNext, +}: { + uploadProfile: (opts: { + firstName: string; + lastName: string; + }) => Promise; + onNext: () => void; +}): JSX.Element { + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [error, setError] = useState(undefined); + + const onChangeFirstName = useCallback( + (event: ChangeEvent) => setFirstName(event.target.value), + [] + ); + + const onChangeLastName = useCallback( + (event: ChangeEvent) => setLastName(event.target.value), + [] + ); + + const onNextClick = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + try { + await uploadProfile({ firstName, lastName }); + onNext(); + } catch (err) { + setError(err.message); + } + }, + [onNext, firstName, lastName, uploadProfile] + ); + + return ( + <> +
+
+
Select Profile Name
+ + + + + {/* TODO(indutny): highlight error */} +
{error}
+
+
+ +
+ + ); +} + +export type PropsType = Readonly<{ + onComplete: () => void; + getCaptchaToken: () => Promise; + requestVerification: ( + number: string, + captcha: string, + transport: VerificationTransport + ) => Promise<{ sessionId: string }>; + registerSingleDevice: ( + number: string, + code: string, + sessionId: string + ) => Promise; + uploadProfile: (opts: { + firstName: string; + lastName: string; + }) => Promise; + readyForUpdates: () => void; +}>; + +export function StandaloneRegistration({ + onComplete, + getCaptchaToken, + requestVerification, + registerSingleDevice, + uploadProfile, + readyForUpdates, +}: PropsType): JSX.Element { + useEffect(() => { + readyForUpdates(); + }, [readyForUpdates]); + + const [stageData, setStageData] = useState({ + 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 = ( + + ); + } else if (stageData.stage === Stage.VerificationCode) { + body = ( + + ); + } else if (stageData.stage === Stage.ProfileName) { + body = ( + + ); + } else { + throw missingCaseError(stageData); + } + return (
-
-
-
-
Create your Signal Account
-
-
- -
-
-
- - -
- -
{error}
-
{status}
-
-
- -
-
+
{body}
); diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index b7849d067..a06af5a33 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -5,6 +5,9 @@ import { useSelector } from 'react-redux'; import type { VerificationTransport } from '../../types/VerificationTransport'; import { App } from '../../components/App'; import OS from '../../util/os/osMain'; +import { getConversation } from '../../util/getConversation'; +import { getChallengeURL } from '../../challenge'; +import { writeProfile } from '../../services/writeProfile'; import { strictAssert } from '../../util/assert'; import { SmartCallManager } from './CallManager'; import { SmartGlobalModalContainer } from './GlobalModalContainer'; @@ -52,6 +55,17 @@ function renderStoryViewer(closeView: () => unknown): JSX.Element { ); } +async function getCaptchaToken(): Promise { + const url = getChallengeURL('registration'); + document.location.href = url; + if (!window.Signal.challengeHandler) { + throw new Error('Captcha handler is not ready!'); + } + return window.Signal.challengeHandler.requestCaptcha({ + reason: 'standalone registration', + }); +} + function requestVerification( number: string, captcha: string, @@ -72,6 +86,28 @@ function registerSingleDevice( .registerSingleDevice(number, code, sessionId); } +function readyForUpdates(): void { + window.IPC.readyForUpdates(); +} + +async function uploadProfile({ + firstName, + lastName, +}: { + firstName: string; + lastName: string; +}): Promise { + const us = window.ConversationController.getOurConversationOrThrow(); + us.set('profileName', firstName); + us.set('profileFamilyName', lastName); + us.captureChange('standaloneProfile'); + await window.Signal.Data.updateConversation(us.attributes); + + await writeProfile(getConversation(us), { + keepAvatar: true, + }); +} + export const SmartApp = memo(function SmartApp() { const appView = useSelector(getAppView); const isMaximized = useSelector(getIsMainWindowMaximized); @@ -90,15 +126,18 @@ export const SmartApp = memo(function SmartApp() { appView={appView} isMaximized={isMaximized} isFullScreen={isFullScreen} + getCaptchaToken={getCaptchaToken} osClassName={osClassName} renderCallManager={renderCallManager} renderGlobalModalContainer={renderGlobalModalContainer} renderLightbox={renderLightbox} hasSelectedStoryData={hasSelectedStoryData} + readyForUpdates={readyForUpdates} renderStoryViewer={renderStoryViewer} renderInbox={renderInbox} requestVerification={requestVerification} registerSingleDevice={registerSingleDevice} + uploadProfile={uploadProfile} theme={theme} openInbox={openInbox} scrollToMessage={scrollToMessage}