Accept profile name during standalone registration

This commit is contained in:
Fedor Indutny 2024-07-15 14:05:09 -07:00 committed by GitHub
parent 8f2061e11d
commit 12b57601ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 447 additions and 103 deletions

View file

@ -631,6 +631,10 @@ $loading-height: 16px;
float: inline-start; float: inline-start;
} }
} }
.StandaloneRegistration__error {
color: $color-accent-red;
}
} }
//yellow border fix //yellow border fix

View file

@ -17,14 +17,20 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
type PropsType = { type PropsType = {
appView: AppViewType; appView: AppViewType;
openInbox: () => void; openInbox: () => void;
getCaptchaToken: () => Promise<string>;
registerSingleDevice: ( registerSingleDevice: (
number: string, number: string,
code: string, code: string,
sessionId: string sessionId: string
) => Promise<void>; ) => Promise<void>;
uploadProfile: (opts: {
firstName: string;
lastName: string;
}) => Promise<void>;
renderCallManager: () => JSX.Element; renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element;
hasSelectedStoryData: boolean; hasSelectedStoryData: boolean;
readyForUpdates: () => void;
renderStoryViewer: (closeView: () => unknown) => JSX.Element; renderStoryViewer: (closeView: () => unknown) => JSX.Element;
renderLightbox: () => JSX.Element | null; renderLightbox: () => JSX.Element | null;
requestVerification: ( requestVerification: (
@ -44,11 +50,13 @@ type PropsType = {
export function App({ export function App({
appView, appView,
getCaptchaToken,
hasSelectedStoryData, hasSelectedStoryData,
isFullScreen, isFullScreen,
isMaximized, isMaximized,
openInbox, openInbox,
osClassName, osClassName,
readyForUpdates,
registerSingleDevice, registerSingleDevice,
renderCallManager, renderCallManager,
renderGlobalModalContainer, renderGlobalModalContainer,
@ -57,6 +65,7 @@ export function App({
renderStoryViewer, renderStoryViewer,
requestVerification, requestVerification,
theme, theme,
uploadProfile,
viewStory, viewStory,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
let contents; let contents;
@ -71,8 +80,11 @@ export function App({
contents = ( contents = (
<StandaloneRegistration <StandaloneRegistration
onComplete={onComplete} onComplete={onComplete}
getCaptchaToken={getCaptchaToken}
readyForUpdates={readyForUpdates}
requestVerification={requestVerification} requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice} registerSingleDevice={registerSingleDevice}
uploadProfile={uploadProfile}
/> />
); );
} else if (appView === AppViewType.Inbox) { } else if (appView === AppViewType.Inbox) {

View file

@ -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<PropsType & { daysAgo?: number }>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => {
return <StandaloneRegistration {...args} />;
};
export const Default = Template.bind({});

View file

@ -7,15 +7,16 @@ import type { Plugin } from 'intl-tel-input';
import intlTelInput from 'intl-tel-input'; import intlTelInput from 'intl-tel-input';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import * as log from '../logging/log';
import { parseNumber } from '../util/libphonenumberUtil'; import { parseNumber } from '../util/libphonenumberUtil';
import { getChallengeURL } from '../challenge'; import { missingCaseError } from '../util/missingCaseError';
import { VerificationTransport } from '../types/VerificationTransport'; import { VerificationTransport } from '../types/VerificationTransport';
function PhoneInput({ function PhoneInput({
initialValue,
onValidation, onValidation,
onNumberChange, onNumberChange,
}: { }: {
initialValue: string | undefined;
onValidation: (isValid: boolean) => void; onValidation: (isValid: boolean) => void;
onNumberChange: (number?: string) => void; onNumberChange: (number?: string) => void;
}): JSX.Element { }): JSX.Element {
@ -23,18 +24,26 @@ function PhoneInput({
const pluginRef = useRef<Plugin | undefined>(); const pluginRef = useRef<Plugin | undefined>();
const elemRef = useRef<HTMLInputElement | null>(null); const elemRef = useRef<HTMLInputElement | null>(null);
const onRef = useCallback((elem: HTMLInputElement | null) => { const onRef = useCallback(
elemRef.current = elem; (elem: HTMLInputElement | null) => {
elemRef.current = elem;
if (!elem) { if (!elem) {
return; return;
} }
pluginRef.current?.destroy(); if (initialValue !== undefined) {
// eslint-disable-next-line no-param-reassign
elem.value = initialValue;
}
const plugin = intlTelInput(elem); pluginRef.current?.destroy();
pluginRef.current = plugin;
}, []); const plugin = intlTelInput(elem);
pluginRef.current = plugin;
},
[initialValue]
);
const validateNumber = useCallback( const validateNumber = useCallback(
(number: string) => { (number: string) => {
@ -94,34 +103,45 @@ function PhoneInput({
); );
} }
export function StandaloneRegistration({ enum Stage {
onComplete, 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, requestVerification,
registerSingleDevice, onNext,
}: { }: {
onComplete: () => void; initialNumber: string | undefined;
getCaptchaToken: () => Promise<string>;
requestVerification: ( requestVerification: (
number: string, number: string,
captcha: string, captcha: string,
transport: VerificationTransport transport: VerificationTransport
) => Promise<{ sessionId: string }>; ) => Promise<{ sessionId: string }>;
registerSingleDevice: ( onNext: (result: { number: string; sessionId: string }) => void;
number: string,
code: string,
sessionId: string
) => Promise<void>;
}): JSX.Element { }): JSX.Element {
useEffect(() => { const [number, setNumber] = useState<string | undefined>(initialNumber);
window.IPC.readyForUpdates();
}, []);
const [isValidNumber, setIsValidNumber] = useState(false); const [isValidNumber, setIsValidNumber] = useState(false);
const [isValidCode, setIsValidCode] = useState(false);
const [number, setNumber] = useState<string | undefined>(undefined);
const [code, setCode] = useState('');
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined);
const onRequestCode = useCallback( const onRequestCode = useCallback(
async (transport: VerificationTransport) => { async (transport: VerificationTransport) => {
@ -135,26 +155,25 @@ export function StandaloneRegistration({
return; 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 { try {
const token = await getCaptchaToken();
const result = await requestVerification(number, token, transport); const result = await requestVerification(number, token, transport);
setSessionId(result.sessionId);
setError(undefined); setError(undefined);
onNext({ number, sessionId: result.sessionId });
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
}, },
[isValidNumber, setIsValidNumber, setError, requestVerification, number] [
getCaptchaToken,
isValidNumber,
setIsValidNumber,
setError,
requestVerification,
number,
onNext,
]
); );
const onSMSClick = useCallback( const onSMSClick = useCallback(
@ -177,6 +196,65 @@ export function StandaloneRegistration({
[onRequestCode] [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( const onChangeCode = useCallback(
(event: ChangeEvent<HTMLInputElement>) => { (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target; const { value } = event.target;
@ -187,12 +265,21 @@ export function StandaloneRegistration({
[setIsValidCode, setCode] [setIsValidCode, setCode]
); );
const onBackClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onBack();
},
[onBack]
);
const onVerifyCode = useCallback( const onVerifyCode = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => { async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!isValidNumber || !isValidCode || !sessionId) { if (!isValidCode || !sessionId) {
return; return;
} }
@ -200,84 +287,246 @@ export function StandaloneRegistration({
try { try {
await registerSingleDevice(number, code, sessionId); await registerSingleDevice(number, code, sessionId);
onComplete(); onNext();
} catch (err) { } catch (err) {
setStatus(err.message); setError(err.message);
} }
}, },
[ [
registerSingleDevice, registerSingleDevice,
onComplete, onNext,
number, number,
code, code,
sessionId, sessionId,
setStatus, setError,
isValidNumber,
isValidCode, 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"
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="First name"
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 ( return (
<div className="full-screen-flow"> <div className="full-screen-flow">
<div className="module-title-bar-drag-area" /> <div className="module-title-bar-drag-area" />
<div className="step"> <div className="step">
<div className="inner"> <div className="inner">{body}</div>
<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
onValidation={setIsValidNumber}
onNumberChange={setNumber}
/>
</div>
</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>
<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>{error}</div>
<div>{status}</div>
</div>
<div className="nav">
<button
type="button"
className="button"
disabled={!isValidNumber || !isValidCode}
onClick={onVerifyCode}
>
Register
</button>
</div>
</div>
</div> </div>
</div> </div>
); );

View file

@ -5,6 +5,9 @@ import { useSelector } from 'react-redux';
import type { VerificationTransport } from '../../types/VerificationTransport'; import type { VerificationTransport } from '../../types/VerificationTransport';
import { App } from '../../components/App'; import { App } from '../../components/App';
import OS from '../../util/os/osMain'; 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 { strictAssert } from '../../util/assert';
import { SmartCallManager } from './CallManager'; import { SmartCallManager } from './CallManager';
import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartGlobalModalContainer } from './GlobalModalContainer';
@ -52,6 +55,17 @@ function renderStoryViewer(closeView: () => unknown): JSX.Element {
); );
} }
async function getCaptchaToken(): Promise<string> {
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( function requestVerification(
number: string, number: string,
captcha: string, captcha: string,
@ -72,6 +86,28 @@ function registerSingleDevice(
.registerSingleDevice(number, code, sessionId); .registerSingleDevice(number, code, sessionId);
} }
function readyForUpdates(): void {
window.IPC.readyForUpdates();
}
async function uploadProfile({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}): Promise<void> {
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() { export const SmartApp = memo(function SmartApp() {
const appView = useSelector(getAppView); const appView = useSelector(getAppView);
const isMaximized = useSelector(getIsMainWindowMaximized); const isMaximized = useSelector(getIsMainWindowMaximized);
@ -90,15 +126,18 @@ export const SmartApp = memo(function SmartApp() {
appView={appView} appView={appView}
isMaximized={isMaximized} isMaximized={isMaximized}
isFullScreen={isFullScreen} isFullScreen={isFullScreen}
getCaptchaToken={getCaptchaToken}
osClassName={osClassName} osClassName={osClassName}
renderCallManager={renderCallManager} renderCallManager={renderCallManager}
renderGlobalModalContainer={renderGlobalModalContainer} renderGlobalModalContainer={renderGlobalModalContainer}
renderLightbox={renderLightbox} renderLightbox={renderLightbox}
hasSelectedStoryData={hasSelectedStoryData} hasSelectedStoryData={hasSelectedStoryData}
readyForUpdates={readyForUpdates}
renderStoryViewer={renderStoryViewer} renderStoryViewer={renderStoryViewer}
renderInbox={renderInbox} renderInbox={renderInbox}
requestVerification={requestVerification} requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice} registerSingleDevice={registerSingleDevice}
uploadProfile={uploadProfile}
theme={theme} theme={theme}
openInbox={openInbox} openInbox={openInbox}
scrollToMessage={scrollToMessage} scrollToMessage={scrollToMessage}