Accept profile name during standalone registration

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-07-15 16:28:24 -05:00 committed by GitHub
parent 6e9b690068
commit bc594b5ae7
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;
}
}
.StandaloneRegistration__error {
color: $color-accent-red;
}
}
//yellow border fix

View file

@ -17,14 +17,20 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
type PropsType = {
appView: AppViewType;
openInbox: () => void;
getCaptchaToken: () => Promise<string>;
registerSingleDevice: (
number: string,
code: string,
sessionId: string
) => Promise<void>;
uploadProfile: (opts: {
firstName: string;
lastName: string;
}) => Promise<void>;
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 = (
<StandaloneRegistration
onComplete={onComplete}
getCaptchaToken={getCaptchaToken}
readyForUpdates={readyForUpdates}
requestVerification={requestVerification}
registerSingleDevice={registerSingleDevice}
uploadProfile={uploadProfile}
/>
);
} 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 { 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<Plugin | undefined>();
const elemRef = useRef<HTMLInputElement | null>(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<string>;
requestVerification: (
number: string,
captcha: string,
transport: VerificationTransport
) => Promise<{ sessionId: string }>;
registerSingleDevice: (
number: string,
code: string,
sessionId: string
) => Promise<void>;
onNext: (result: { number: string; sessionId: string }) => void;
}): JSX.Element {
useEffect(() => {
window.IPC.readyForUpdates();
}, []);
const [number, setNumber] = useState<string | undefined>(initialNumber);
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 [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(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 (
<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;
@ -187,12 +265,21 @@ export function StandaloneRegistration({
[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 (!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 (
<>
<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 (
<div className="full-screen-flow">
<div className="module-title-bar-drag-area" />
<div className="step">
<div className="inner">
<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 className="inner">{body}</div>
</div>
</div>
);

View file

@ -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<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(
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<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() {
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}