From d00aaf133b7ae258b0a50eed390ec34c729f7582 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 22 Jul 2026 03:10:11 +1000 Subject: [PATCH] Standalone Registration: Support for Verify PIN scenarios --- _locales/en/messages.json | 10 +- package.json | 2 +- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 6 + ts/axo/AxoTextField.dom.tsx | 4 +- .../StandaloneRegistration.dom.stories.tsx | 229 ++++++- .../stages/CreatePIN.dom.tsx | 101 +-- .../stages/CreatePINConfirm.dom.tsx | 4 +- .../stages/PhoneNumber.dom.tsx | 5 +- .../stages/ProfileEntry.dom.tsx | 9 +- .../stages/VerificationCode.dom.tsx | 8 +- .../stages/VerifyPIN.dom.tsx | 623 +++++++++++++++--- .../util/StepComponents.dom.tsx | 15 +- ts/state/ducks/standaloneInstaller.preload.ts | 11 +- .../standalone/registration_test.node.ts | 148 ++++- ts/types/StandaloneRegistration.std.ts | 2 +- 16 files changed, 987 insertions(+), 200 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 566152b2dc..8436437458 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -9284,6 +9284,10 @@ "messageformat": "Incorrect PIN. {triesRemaining, plural, one {# attempt remaining} other {# attempts remaining}}.", "description": "Error shown when entered PIN is incorrect, and " }, + "icu:StandaloneRegistration--VerifyPIN--just-count": { + "messageformat": "{triesRemaining, plural, one {# attempt remaining} other {# attempts remaining}}.", + "description": "Shown when few tries remain as the user attempts to enter the proper PIN" + }, "icu:StandaloneRegistration--VerifyPIN--NeedHelp--header": { "messageformat": "Need help?", "description": "Header for dialog shown when user clicks Need Help?" @@ -9340,6 +9344,10 @@ "messageformat": "Learn more about PINs", "description": "Button for dialog shown when user has tried an incorrect PIN there are no remaining tries left" }, + "icu:StandaloneRegistration--VerifyPIN--open-menu": { + "messageformat": "Open menu", + "description": "Accessibility text for the triple-dot button on the screen asking user to verify their PIN" + }, "icu:StandaloneRegistration--VerifyPIN--menu--skip-pin": { "messageformat": "Skip PIN", "description": "Menu item on Verify PIN screens, allowing skip of restoring data with PIN, instead starting over from scratch with a new PIN" @@ -9349,7 +9357,7 @@ "description": "Header for dialog shown when user chooses to skip PIN entry on the Verify PIN screen" }, "icu:StandaloneRegistration--VerifyPIN--SkipPIN--description": { - "messageformat": "If you can’t remember your PIN, you can create a new one. You can register and use your account but you’ll lose some saved settings like your profile information. Learn more.", + "messageformat": "If you can’t remember your PIN, you can create a new one. You can register and use your account but you’ll lose some saved settings like your profile information. Learn more.", "description": "Description for dialog shown when user chooses to skip PIN entry on the Verify PIN screen" }, "icu:StandaloneRegistration--VerifyPIN--SkipPIN--skip-button": { diff --git a/package.json b/package.json index 860b556786..b4cd09c2cb 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "@react-spring/web": "10.0.3", "@signalapp/lame": "workspace:*", "@signalapp/minimask": "1.0.1", - "@signalapp/mock-server": "24.0.3", + "@signalapp/mock-server": "25.0.0", "@signalapp/parchment-cjs": "3.0.1", "@signalapp/quill-cjs": "2.1.2", "@storybook/addon-a11y": "8.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccf44e6ddf..62e1d7f041 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,8 +153,8 @@ importers: specifier: 1.0.1 version: 1.0.1 '@signalapp/mock-server': - specifier: 24.0.3 - version: 24.0.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + specifier: 25.0.0 + version: 25.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@signalapp/parchment-cjs': specifier: 3.0.1 version: 3.0.1 @@ -4473,8 +4473,8 @@ packages: '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==, tarball: https://registry.npmjs.org/@signalapp/minimask/-/minimask-1.0.1.tgz} - '@signalapp/mock-server@24.0.3': - resolution: {integrity: sha512-SGV0S6WvYYZRBx+F2Z+xLiTrxxtDaF8wU+orqKXWs4KU8/u1es+di6Eaoat2U9hC/eOU85hEZrpqMHVO5ad9CA==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-24.0.3.tgz} + '@signalapp/mock-server@25.0.0': + resolution: {integrity: sha512-gEOYKlwppfs8XT7feg82NQKab8XOVAaQw2rAOGth60BVpzeZI0eiTF2JoNBjVdLbJamFX8Cz2CrZAyXvJhp7Jg==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-25.0.0.tgz} '@signalapp/parchment-cjs@3.0.1': resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==, tarball: https://registry.npmjs.org/@signalapp/parchment-cjs/-/parchment-cjs-3.0.1.tgz} @@ -15249,7 +15249,7 @@ snapshots: '@signalapp/minimask@1.0.1': {} - '@signalapp/mock-server@24.0.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@signalapp/mock-server@25.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@indutny/parallel-prettier': 3.0.0(prettier@3.8.3) '@indutny/protopiler': 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 012bbdb315..c30c5f8be3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,6 +41,12 @@ auditConfig: - GHSA-9wfr-w7mm-pc7f # react-devtools > electron - GHSA-c27g-q93r-2cwf # sticker-creator > vite - GHSA-2w69-qvjg-hvjx # sticker-creator > @remix-run/router + - GHSA-23hp-3jrh-7fpw # dev-time usage + - GHSA-3jxr-9vmj-r5cp # dev tools + - GHSA-52cp-r559-cp3m # dev tools + - GHSA-8x88-c5mf-7j5w # dev-time usage + - GHSA-395f-4hp3-45gv # dev tools + - GHSA-gcfj-64vw-6mp9 # storybook overrides: '@electron/rebuild>node-gyp': '12.4.0' diff --git a/ts/axo/AxoTextField.dom.tsx b/ts/axo/AxoTextField.dom.tsx index 84272cdfec..74ae742fb6 100644 --- a/ts/axo/AxoTextField.dom.tsx +++ b/ts/axo/AxoTextField.dom.tsx @@ -212,6 +212,8 @@ export namespace AxoTextField { autoFocus?: boolean; /** Enables or disables browser spell checking. */ spellCheck?: boolean; + /** Default is 'text', a normal text box. Can be used to input into a password field. */ + type?: 'text' | 'password'; }>; /** The text input field. Must be placed inside `Root`. */ @@ -305,7 +307,7 @@ export namespace AxoTextField { ; const phoneNumber = '+14155551111'; +const verificationSessionId = 'verification-session-id'; export default meta; type Story = StoryObj; @@ -171,7 +172,7 @@ export const Captcha: Story = { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, }, }, }; @@ -187,7 +188,7 @@ export const CaptchaInProgress: Story = { type: 'in-progress', startedAt: Date.now() - 25 * SECOND, }, - verificationSessionId: 'verification-session-id', + verificationSessionId, }, }, }; @@ -199,7 +200,7 @@ export const CaptchaWithWaitTime: Story = { stage: RegistrationStage.CAPTCHA, captchaCompleteCount: 0, phoneNumber, - verificationSessionId: 'verification-session-id', + verificationSessionId, status: { type: 'waiting', canDoCaptchaAt: Date.now() + MINUTE * 5, @@ -218,7 +219,7 @@ export const CaptchaWithAnotherNeeded: Story = { status: { type: 'another-needed', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, }, }, }; @@ -233,7 +234,7 @@ export const CaptchaWithFailure: Story = { status: { type: 'failed', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, }, }, }; @@ -249,7 +250,7 @@ export const VerificationCode: Story = { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -267,7 +268,7 @@ export const VerificationCodeWhileSubmittingCode: Story = { status: { type: 'submitting-code', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -286,7 +287,7 @@ export const VerificationCodeWhileRequestingSMS: Story = { type: 'requesting-code', transport: VerificationTransport.SMS, }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -305,7 +306,7 @@ export const VerificationCodeWhileRequestingCall: Story = { type: 'requesting-code', transport: VerificationTransport.Voice, }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -323,7 +324,7 @@ export const VerificationCodeWithFailedToSendSMS: Story = { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: true, failedToCall: false, }, @@ -340,7 +341,7 @@ export const VerificationCodeWithFailedToCall: Story = { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: true, }, @@ -357,7 +358,7 @@ export const VerificationCodeWithHavingTrouble: Story = { type: 'ready', }, phoneNumber, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -374,7 +375,7 @@ export const VerificationCodeWithMaximumAttempts: Story = { type: 'ready', }, phoneNumber, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -392,7 +393,7 @@ export const VerificationCodeWithAllDelays: Story = { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, timings: { @@ -416,7 +417,7 @@ export const VerificationCodeWithIncorrectCode: Story = { type: 'failed', error: 'incorrect-code', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -435,7 +436,7 @@ export const VerificationCodeWithInvalidCode: Story = { type: 'failed', error: 'invalid-code', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -458,7 +459,7 @@ export const VerificationCodeWithCannotSendCodeTemporary: Story = { smsCanBeRequestedAt: Date.now() + 5 * MINUTE, codeCanBeSubmittedAt: Date.now() + 5 * MINUTE, }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -476,7 +477,7 @@ export const VerificationCodeWithCannotSendCodePermanent: Story = { type: 'failed', error: 'cannot-send-code-permanent', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }, @@ -546,6 +547,194 @@ export const VerifyPIN: Story = { }, }, }; +export const VerifyPINInProgress: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'in-progress', + }, + }, + }, +}; +export const VerifyPINIncorrectPinTries3: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 3, + }, + }, +}; +export const VerifyPINIncorrectPinTries2: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 2, + }, + }, +}; +export const VerifyPINIncorrectPinTries1: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 1, + }, + }, +}; +export const VerifyPINIncorrectPinTries0: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 0, + }, + }, +}; + +export const VerifyPINStartingWithTries2: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'ready', + }, + triesRemaining: 2, + }, + }, +}; +export const VerifyPINStartingWithTries1: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'ready', + }, + triesRemaining: 1, + }, + }, +}; + +const dataForReglockAccountCreate = { + phoneNumber, + verificationSessionId, + svrCredentials: { + username: 'fake-username', + password: 'fake-password', + }, + profileData: { + firstName: 'John', + phoneNumberDiscoverability: PhoneNumberDiscoverability.Discoverable, + }, +}; + +export const VerifyPINWithReglock: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'ready', + }, + dataForReglockAccountCreate, + }, + }, +}; + +export const VerifyPINWithReglockAndIncorrectPinTries5: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 5, + dataForReglockAccountCreate, + }, + }, +}; + +export const VerifyPINWithReglockAndIncorrectPinTries3: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 3, + dataForReglockAccountCreate, + }, + }, +}; + +export const VerifyPINWithReglockAndIncorrectPinTries1: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 1, + dataForReglockAccountCreate, + }, + }, +}; + +export const VerifyPINWithReglockAndIncorrectPinTries0: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'failed', + error: 'incorrect-pin', + }, + triesRemaining: 0, + dataForReglockAccountCreate, + }, + }, +}; + +export const VerifyPINWithReglockStartingWithTries1: Story = { + args: { + ...StartingScreen.args, + workflow: { + stage: RegistrationStage.VERIFY_PIN, + status: { + type: 'ready', + }, + triesRemaining: 1, + dataForReglockAccountCreate, + }, + }, +}; export const CreatePIN: Story = { args: { @@ -634,7 +823,7 @@ export function ProgressionWithVerify(): React.JSX.Element { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, }); }, moveToVerificationStage: async (...params: Array) => { @@ -647,7 +836,7 @@ export function ProgressionWithVerify(): React.JSX.Element { status: { type: 'ready', }, - verificationSessionId: 'verification-session-id', + verificationSessionId, failedToSendSMS: false, failedToCall: false, }); diff --git a/ts/components/standaloneRegistration/stages/CreatePIN.dom.tsx b/ts/components/standaloneRegistration/stages/CreatePIN.dom.tsx index a95a9742c0..3939fdcea1 100644 --- a/ts/components/standaloneRegistration/stages/CreatePIN.dom.tsx +++ b/ts/components/standaloneRegistration/stages/CreatePIN.dom.tsx @@ -18,6 +18,7 @@ import { Container, Description, InputContainer, + PIN_ARTICLE_ON_SUPPORT, Spacer, Title, TopMatter, @@ -30,6 +31,7 @@ import type { completeRegistration as doCompleteRegistration, startConfirmingPIN as doStartConfirmingPIN, } from '../../../state/ducks/standaloneInstaller.preload.ts'; +import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.ts'; const PIN_LENGTH_MINIMUM = 4; @@ -60,46 +62,41 @@ export function CreatePINScreen({ [setIsValidPIN, setPIN] ); + const topRightMenu = ( + + + + + + + + { + setIsShowingAboutPINsDialog(true); + }} + > + {i18n('icu:StandaloneRegistration--CreatePIN--menu--more-about')} + + { + setIsShowingDisablePINDialog(true); + }} + > + {i18n('icu:StandaloneRegistration--CreatePIN--menu--disable')} + + + + ); + return ( - - - - - - - - { - setIsShowingAboutPINsDialog(true); - }} - > - {i18n( - 'icu:StandaloneRegistration--CreatePIN--menu--more-about' - )} - - { - setIsShowingDisablePINDialog(true); - }} - > - {i18n('icu:StandaloneRegistration--CreatePIN--menu--disable')} - - - - } - /> + <Description className={tw('w-100')}> @@ -109,10 +106,7 @@ export function CreatePINScreen({ components={{ learnMore: parts => { return ( - <a - className={tw('text-primary')} - href="https://support.signal.org/hc/articles/360007059792-Signal-PIN" - > + <a className={tw('text-primary')} href={PIN_ARTICLE_ON_SUPPORT}> {parts} </a> ); @@ -121,8 +115,19 @@ export function CreatePINScreen({ /> </Description> <Spacer className={tw('h-8')} /> - <InputContainer> - <AxoTextField.Root width="sm"> + <InputContainer + className={tw('w-81')} + helperElement={ + <div + className={tw( + 'ms-1 mt-1.5 w-full text-start type-body-small text-secondary' + )} + > + {i18n('icu:StandaloneRegistration--CreatePIN--helper-text')} + </div> + } + > + <AxoTextField.Root width="lg"> <AxoTextField.Input autoFocus maxBytes={10} @@ -131,13 +136,11 @@ export function CreatePINScreen({ placeholder={i18n( 'icu:StandaloneRegistration--CreatePIN--placeholder' )} + type="password" value={pin} /> </AxoTextField.Root> </InputContainer> - <div className={tw('mt-2 type-body-small text-secondary')}> - {i18n('icu:StandaloneRegistration--CreatePIN--helper-text')} - </div> <Spacer className={tw('grow')} /> <Buttons> <AxoButton.Root @@ -207,9 +210,7 @@ function AboutPINsDialog({ <AxoConfirmDialog.Action variant="strong-secondary" onClick={() => { - // TODO: double-check location - window.location.href = - 'https://support.signal.org/hc/articles/360007059792-Signal-PIN'; + openLinkInWebBrowser(PIN_ARTICLE_ON_SUPPORT); setOpen(false); }} > diff --git a/ts/components/standaloneRegistration/stages/CreatePINConfirm.dom.tsx b/ts/components/standaloneRegistration/stages/CreatePINConfirm.dom.tsx index a24875184d..4f5df4659b 100644 --- a/ts/components/standaloneRegistration/stages/CreatePINConfirm.dom.tsx +++ b/ts/components/standaloneRegistration/stages/CreatePINConfirm.dom.tsx @@ -62,15 +62,17 @@ export function CreatePINConfirmScreen({ {i18n('icu:StandaloneRegistration--CreatePIN--confirming--description')} </Description> <Spacer className={tw('h-8')} /> - <InputContainer> + <InputContainer className={tw('w-81')}> <AxoTextField.Root width="md" disabled={pending}> <AxoTextField.Input + autoFocus maxBytes={10} maxGraphemes={10} onValueChange={onChangePIN} placeholder={i18n( 'icu:StandaloneRegistration--CreatePIN--confirming--placeholder' )} + type="password" value={pin} /> </AxoTextField.Root> diff --git a/ts/components/standaloneRegistration/stages/PhoneNumber.dom.tsx b/ts/components/standaloneRegistration/stages/PhoneNumber.dom.tsx index 836d2ca90e..6a54778077 100644 --- a/ts/components/standaloneRegistration/stages/PhoneNumber.dom.tsx +++ b/ts/components/standaloneRegistration/stages/PhoneNumber.dom.tsx @@ -16,6 +16,7 @@ import { Buttons, Container, Description, + InputContainer, Spacer, Title, TopMatter, @@ -99,7 +100,7 @@ export function PhoneNumberScreen({ </div> </Description> <Spacer className={tw('h-9')} /> - <div className={tw('w-81 max-w-[calc(100%-48px)]')}> + <InputContainer className={tw('w-81')}> <AxoTextField.Root width="lg"> {regionCode ? ( <div className={tw('p-1.5 ps-3 type-body-large text-primary')}> @@ -125,7 +126,7 @@ export function PhoneNumberScreen({ value={phoneNumber ?? ''} /> </AxoTextField.Root> - </div> + </InputContainer> <Spacer className={tw('grow')} /> {status.type === 'waiting' && duration ? ( <div> diff --git a/ts/components/standaloneRegistration/stages/ProfileEntry.dom.tsx b/ts/components/standaloneRegistration/stages/ProfileEntry.dom.tsx index d258376919..79afa43781 100644 --- a/ts/components/standaloneRegistration/stages/ProfileEntry.dom.tsx +++ b/ts/components/standaloneRegistration/stages/ProfileEntry.dom.tsx @@ -96,7 +96,10 @@ export function ProfileEntryScreen({ {i18n('icu:StandaloneRegistration--ProfileEntry--description')} </div> <div> - <a href="https://support.signal.org"> + <a + className={tw('text-primary')} + href="https://support.signal.org/hc/articles/360007459591-Signal-Profiles-and-Message-Requests" + > {i18n('icu:StandaloneRegistration--ProfileEntry--learn-more')} </a> </div> @@ -134,7 +137,7 @@ export function ProfileEntryScreen({ {i18n('icu:StandaloneRegistration--ProfileEntry--add-photo')} </AxoButton.Root> <Spacer className={tw('h-7')} /> - <InputContainer> + <InputContainer className={tw('w-100')}> <AxoTextField.Root width="md" disabled={pending}> <AxoTextField.Input placeholder={i18n( @@ -148,7 +151,7 @@ export function ProfileEntryScreen({ </AxoTextField.Root> </InputContainer> <Spacer /> - <InputContainer> + <InputContainer className={tw('w-100')}> <AxoTextField.Root width="md" disabled={pending}> <AxoTextField.Input placeholder={i18n( diff --git a/ts/components/standaloneRegistration/stages/VerificationCode.dom.tsx b/ts/components/standaloneRegistration/stages/VerificationCode.dom.tsx index 5f6cca909b..1417521e2d 100644 --- a/ts/components/standaloneRegistration/stages/VerificationCode.dom.tsx +++ b/ts/components/standaloneRegistration/stages/VerificationCode.dom.tsx @@ -151,13 +151,17 @@ export function VerificationCodeScreen({ timings || {}; const updateCodeSubmitCountdown = useCallback( (newDuration: string | undefined) => { - if (!waitSubmitDuration && newDuration) { + if ( + !waitSubmitDuration && + newDuration && + workflow.status.type !== 'failed' // we have error-specific dialogs + ) { setMustWaitDialogOpen(true); } setWaitSubmitDuration(newDuration); }, - [waitSubmitDuration, setWaitSubmitDuration, setMustWaitDialogOpen] + [setMustWaitDialogOpen, setWaitSubmitDuration, waitSubmitDuration, workflow] ); useCountdownDuration({ timestamp: codeCanBeSubmittedAt, diff --git a/ts/components/standaloneRegistration/stages/VerifyPIN.dom.tsx b/ts/components/standaloneRegistration/stages/VerifyPIN.dom.tsx index 7da8b6b4f3..e5b8ab20f8 100644 --- a/ts/components/standaloneRegistration/stages/VerifyPIN.dom.tsx +++ b/ts/components/standaloneRegistration/stages/VerifyPIN.dom.tsx @@ -1,12 +1,23 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; -import type { ChangeEvent, JSX } from 'react'; +import type { JSX, ReactNode } from 'react'; import { I18n } from '../../I18n.dom.tsx'; import { AxoButton } from '../../../axo/AxoButton.dom.tsx'; +import { tw } from '../../../axo/tw.dom.tsx'; +import { + Buttons, + Container, + Description, + InputContainer, + PIN_ARTICLE_ON_SUPPORT, + Spacer, + Title, + TopMatter, +} from '../util/StepComponents.dom.tsx'; import type { LocalizerType } from '../../../types/I18N.std.ts'; import type { ActionCreator } from '../../../state/types.std.ts'; @@ -16,6 +27,18 @@ import type { goToCreatePINStage as doGoToCreatePINStage, verifyPIN as doVerifyPIN, } from '../../../state/ducks/standaloneInstaller.preload.ts'; +import { AxoTextField } from '../../../axo/AxoTextField.dom.tsx'; +import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.tsx'; +import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.ts'; +import { CONTACT_SUPPORT_URL } from '../../../util/contactSupport.dom.tsx'; +import { AxoDropdownMenu } from '../../../axo/AxoDropdownMenu.dom.tsx'; +import { AxoSymbol } from '../../../axo/AxoSymbol.dom.tsx'; +import { isNumber } from 'lodash'; + +const FIRST_REGLOCK_TRIES_THRESHOLD = 5; +const SECOND_REGLOCK_TRIES_THRESHOLD = 3; +const FIRST_NON_REGLOCK_TRIES_THRESHOLD = 3; +const SECOND_NON_REGLOCK_TRIES_THRESHOLD = 1; export function VerifyPINScreen({ verifyPIN, @@ -32,112 +55,536 @@ export function VerifyPINScreen({ }): JSX.Element { const [pin, setPIN] = useState(''); const [isValidPIN, setIsValidPIN] = useState(false); + const [needHelpDialogOpen, setNeedHelpDialogOpen] = useState(false); + const [skipPINDialogOpen, setSkipPINDialogOpen] = useState(false); + const [fewRemainingTriesDialogOpen, setFewRemainingTriesDialogOpen] = + useState(false); + const [noRemainingTriesDialogOpen, setNoRemainingTriesDialogOpen] = + useState(false); + + const [shouldShowIncorrectPIN, setShouldShowIncorrectPIN] = useState< + | { type: 'error' } + | { type: 'error-with-count'; count: number } + | { type: 'count'; count: number } + | undefined + >(undefined); + const inputRef = useRef<HTMLInputElement>(null); const onChangePIN = useCallback( - (event: ChangeEvent<HTMLInputElement>) => { - const { value } = event.target; - + (value: string) => { + inputRef.current?.setCustomValidity(''); setIsValidPIN(value.length === 6); setPIN(value); }, [setIsValidPIN, setPIN] ); - // TODO: show below input box if entered PIN is incorrect - // icu:StandaloneRegistration--VerifyPIN--incorrect-pin - // icu:StandaloneRegistration--VerifyPIN--incorrect-pin--with-count + const { status } = workflow; + const pending = status.type === 'in-progress'; + const isIncorrectPin = + status.type === 'failed' && status.error === 'incorrect-pin'; - // TODO: Need Help dialog - // icu:StandaloneRegistration--VerifyPIN--NeedHelp--header - // icu:StandaloneRegistration--VerifyPIN--NeedHelp--description--no-reglock - // icu:StandaloneRegistration--VerifyPIN--NeedHelp--description--reglock - // icu:StandaloneRegistration--VerifyPIN--NeedHelp--skip-button--no-reglock - // icu:StandaloneRegistration--VerifyPIN--NeedHelp--contact-support-button - // icu:StandaloneRegistration--VerifyPIN--NeedHelp--cancel + const hasReglock = Boolean(workflow.dataForReglockAccountCreate); - // TODO: Incorrect pin, N attempts remaining dialog - // icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--header - // icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--description--no-reglock - // icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--description--reglock - // icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--button + const menu = hasReglock ? undefined : ( + <AxoDropdownMenu.Root> + <AxoDropdownMenu.Trigger> + <AxoButton.Root variant="implied-secondary" size="md"> + <AxoSymbol.Icon + symbol="more" + size={20} + label={i18n('icu:StandaloneRegistration--VerifyPIN--open-menu')} + /> + </AxoButton.Root> + </AxoDropdownMenu.Trigger> + <AxoDropdownMenu.Content> + <AxoDropdownMenu.Item + symbol="arrow-circle-[end]" + onSelect={() => { + setSkipPINDialogOpen(true); + }} + > + {i18n('icu:StandaloneRegistration--VerifyPIN--menu--skip-pin')} + </AxoDropdownMenu.Item> + </AxoDropdownMenu.Content> + </AxoDropdownMenu.Root> + ); - // TODO: Incorrect PIN, no attempts remaining dialog - // icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--header - // icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--description - // icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--create-new-pin - // icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--learn-more + const { triesRemaining } = workflow; + const previousTriesRemaining = useRef<number | undefined>(undefined); - // TODO: menu with one item: - // icu:StandaloneRegistration--VerifyPIN--menu--skip-pin + let helperElement: ReactNode | undefined; - // TODO: icu:StandaloneRegistration--VerifyPIN--SkipPIN--header - // icu:StandaloneRegistration--VerifyPIN--SkipPIN--description - // icu:StandaloneRegistration--VerifyPIN--SkipPIN--skip-button - // icu:StandaloneRegistration--VerifyPIN--SkipPIN--cancel-button + if ( + previousTriesRemaining.current !== triesRemaining && + isNumber(triesRemaining) + ) { + previousTriesRemaining.current = triesRemaining; + + if ( + hasReglock && + (triesRemaining === FIRST_REGLOCK_TRIES_THRESHOLD || + triesRemaining === SECOND_REGLOCK_TRIES_THRESHOLD) + ) { + setFewRemainingTriesDialogOpen(true); + } + if (hasReglock && triesRemaining === 0) { + goToAccountLockedStage(); + } + + if ( + !hasReglock && + (triesRemaining === FIRST_NON_REGLOCK_TRIES_THRESHOLD || + triesRemaining === SECOND_NON_REGLOCK_TRIES_THRESHOLD) + ) { + setFewRemainingTriesDialogOpen(true); + } + if (!hasReglock && triesRemaining === 0) { + setNoRemainingTriesDialogOpen(true); + } + + if ( + (hasReglock && triesRemaining <= FIRST_REGLOCK_TRIES_THRESHOLD) || + (!hasReglock && triesRemaining <= FIRST_NON_REGLOCK_TRIES_THRESHOLD) + ) { + if (isIncorrectPin) { + inputRef.current?.setCustomValidity('invalid'); + setShouldShowIncorrectPIN({ + type: 'error-with-count', + count: triesRemaining, + }); + } else { + inputRef.current?.setCustomValidity('invalid'); + setShouldShowIncorrectPIN({ + type: 'count', + count: triesRemaining, + }); + } + } else if (isIncorrectPin) { + inputRef.current?.setCustomValidity('invalid'); + setShouldShowIncorrectPIN({ type: 'error' }); + } + } + + if (shouldShowIncorrectPIN?.type === 'count') { + helperElement = ( + <div + className={tw( + 'ms-1 mt-1.5 w-full text-start type-body-small text-destructive' + )} + > + {i18n('icu:StandaloneRegistration--VerifyPIN--just-count', { + triesRemaining: shouldShowIncorrectPIN.count, + })} + </div> + ); + } + if (shouldShowIncorrectPIN?.type === 'error-with-count') { + helperElement = ( + <div + className={tw( + 'ms-1 mt-1.5 w-full text-start type-body-small text-destructive' + )} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--incorrect-pin--with-count', + { triesRemaining: shouldShowIncorrectPIN.count } + )} + </div> + ); + } else if (shouldShowIncorrectPIN?.type === 'error') { + helperElement = ( + <div + className={tw( + 'ms-1 mt-1.5 w-full text-start type-body-small text-destructive' + )} + > + {i18n('icu:StandaloneRegistration--VerifyPIN--incorrect-pin')} + </div> + ); + } return ( - <> - <div className="step-body"> - <div className="header"> - {i18n('icu:StandaloneRegistration--VerifyPIN--header')} - </div> - - <div> - <I18n - i18n={i18n} - id="icu:StandaloneRegistration--VerifyPIN--description" - components={{ - needHelp: parts => { - return ( - <a href="https://support.signal.org/hc/articles/360007059792-Signal-PIN"> - {parts} - </a> - ); - }, - }} - /> - </div> - {/* FIXME */} - {/* oxlint-disable-next-line jsx-a11y/control-has-associated-label */} - <input - className={`form-control ${isValidPIN ? '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={i18n( - 'icu:StandaloneRegistration--VerifyPIN--placeholder' - )} - autoComplete="off" - value={pin} - onChange={onChangePIN} + <Container> + <TopMatter i18n={i18n} rightContent={menu} /> + <Spacer className={tw('h-14')} /> + <Title text={i18n('icu:StandaloneRegistration--VerifyPIN--header')} /> + <Description> + <I18n + i18n={i18n} + id="icu:StandaloneRegistration--VerifyPIN--description" + components={{ + needHelp: parts => { + return ( + <button + type="button" + className={tw('text-primary')} + onClick={e => { + setNeedHelpDialogOpen(true); + e.preventDefault(); + e.stopPropagation(); + }} + onKeyUp={e => { + if (e.key === 'Enter') { + setNeedHelpDialogOpen(true); + e.preventDefault(); + e.stopPropagation(); + } + }} + > + {parts} + </button> + ); + }, + }} /> - </div> - <div className="nav"> - <AxoButton.Root - variant="implied-primary" - size="md" - onClick={() => goToCreatePINStage()} - > - TODO: Create new PIN - </AxoButton.Root> - <AxoButton.Root - variant="implied-primary" - size="md" - onClick={() => goToAccountLockedStage()} - > - TODO: Account is locked! - </AxoButton.Root> - + </Description> + <Spacer className={tw('h-10')} /> + <InputContainer className={tw('w-81')} helperElement={helperElement}> + <AxoTextField.Root width="lg"> + <AxoTextField.Input + autoFocus + maxBytes={10} + maxGraphemes={10} + onValueChange={onChangePIN} + disabled={pending} + placeholder={i18n( + 'icu:StandaloneRegistration--VerifyPIN--placeholder' + )} + ref={inputRef} + type="password" + value={pin} + /> + </AxoTextField.Root> + </InputContainer> + <Spacer className={tw('grow')} /> + <Buttons> <AxoButton.Root variant="strong-primary" size="md" - disabled={!isValidPIN} - onClick={() => verifyPIN({ pin, workflow })} + pending={pending} + disabled={!isValidPIN || pending} + onClick={() => { + setShouldShowIncorrectPIN(undefined); + verifyPIN({ pin, workflow }); + }} > - TODO: Finish + {i18n('icu:StandaloneRegistration--ProfileEntry--continue')} </AxoButton.Root> - </div> - </> + </Buttons> + <NeedHelpDialog + i18n={i18n} + isOpen={needHelpDialogOpen} + setOpen={setNeedHelpDialogOpen} + hasReglock={hasReglock} + goToCreatePINStage={goToCreatePINStage} + /> + <SkipPINDialog + i18n={i18n} + isOpen={skipPINDialogOpen} + setOpen={setSkipPINDialogOpen} + goToCreatePINStage={goToCreatePINStage} + /> + <FewRemainingTriesDialog + i18n={i18n} + isOpen={fewRemainingTriesDialogOpen} + setOpen={setFewRemainingTriesDialogOpen} + hasReglock={hasReglock} + triesRemaining={triesRemaining ?? 0} + /> + <NoRemainingTriesDialog + i18n={i18n} + isOpen={noRemainingTriesDialogOpen} + setOpen={setNoRemainingTriesDialogOpen} + goToCreatePINStage={goToCreatePINStage} + /> + </Container> + ); +} + +const learnMore = (parts: ReactNode) => { + return ( + <a className={tw('text-primary')} href={PIN_ARTICLE_ON_SUPPORT}> + {parts} + </a> + ); +}; + +function NeedHelpDialog({ + // Housekeeping + i18n, + isOpen, + setOpen, + // Specifics + hasReglock, + goToCreatePINStage, +}: { + // Housekeeping + i18n: LocalizerType; + isOpen: boolean; + setOpen: (value: boolean) => unknown; + // Specifics + hasReglock: boolean; + goToCreatePINStage: ActionCreator<typeof doGoToCreatePINStage>; +}): JSX.Element { + return ( + <AxoAlertDialog.Root + open={isOpen} + onOpenChange={open => { + if (!open) { + setOpen(false); + } + }} + > + <AxoAlertDialog.Content escape="cancel-is-noop"> + <AxoAlertDialog.Body> + <AxoAlertDialog.Title> + <div className={tw('text-start')}> + {i18n('icu:StandaloneRegistration--VerifyPIN--NeedHelp--header')} + </div> + </AxoAlertDialog.Title> + <AxoAlertDialog.Description> + <div className={tw('text-start')}> + {hasReglock ? ( + <I18n + i18n={i18n} + id="icu:StandaloneRegistration--VerifyPIN--NeedHelp--description--reglock" + components={{ learnMore }} + /> + ) : ( + <I18n + i18n={i18n} + id="icu:StandaloneRegistration--VerifyPIN--NeedHelp--description--no-reglock" + components={{ learnMore }} + /> + )} + </div> + </AxoAlertDialog.Description> + </AxoAlertDialog.Body> + <AxoAlertDialog.Footer forceAlwaysBreakToSeparateLines> + <AxoAlertDialog.Action + variant="strong-secondary" + onClick={() => setOpen(false)} + > + {i18n('icu:StandaloneRegistration--VerifyPIN--NeedHelp--cancel')} + </AxoAlertDialog.Action> + <AxoAlertDialog.Action + variant="strong-secondary" + onClick={() => openLinkInWebBrowser(CONTACT_SUPPORT_URL)} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--NeedHelp--contact-support-button' + )} + </AxoAlertDialog.Action> + {hasReglock ? undefined : ( + <AxoAlertDialog.Action + variant="subtle-destructive" + onClick={() => goToCreatePINStage()} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--NeedHelp--skip-button--no-reglock' + )} + </AxoAlertDialog.Action> + )} + </AxoAlertDialog.Footer> + </AxoAlertDialog.Content> + </AxoAlertDialog.Root> + ); +} +function SkipPINDialog({ + // Housekeeping + i18n, + isOpen, + setOpen, + // Specifics + goToCreatePINStage, +}: { + // Housekeeping + i18n: LocalizerType; + isOpen: boolean; + setOpen: (value: boolean) => unknown; + // Specifics + goToCreatePINStage: ActionCreator<typeof doGoToCreatePINStage>; +}): JSX.Element { + return ( + <AxoAlertDialog.Root + open={isOpen} + onOpenChange={open => { + if (!open) { + setOpen(false); + } + }} + > + <AxoAlertDialog.Content escape="cancel-is-noop"> + <AxoAlertDialog.Body> + <AxoAlertDialog.Title> + <div className={tw('text-start')}> + {i18n('icu:StandaloneRegistration--VerifyPIN--SkipPIN--header')} + </div> + </AxoAlertDialog.Title> + <AxoAlertDialog.Description> + <div className={tw('text-start')}> + <I18n + i18n={i18n} + id="icu:StandaloneRegistration--VerifyPIN--SkipPIN--description" + components={{ + learnMore, + }} + /> + </div> + </AxoAlertDialog.Description> + </AxoAlertDialog.Body> + <AxoAlertDialog.Footer forceAlwaysBreakToSeparateLines> + <AxoAlertDialog.Action + variant="strong-secondary" + onClick={() => setOpen(false)} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--SkipPIN--cancel-button' + )} + </AxoAlertDialog.Action> + <AxoAlertDialog.Action + variant="subtle-destructive" + onClick={() => goToCreatePINStage()} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--SkipPIN--skip-button' + )} + </AxoAlertDialog.Action> + </AxoAlertDialog.Footer> + </AxoAlertDialog.Content> + </AxoAlertDialog.Root> + ); +} + +function FewRemainingTriesDialog({ + // Housekeeping + i18n, + isOpen, + setOpen, + // Specifics + hasReglock, + triesRemaining, +}: { + // Housekeeping + i18n: LocalizerType; + isOpen: boolean; + setOpen: (value: boolean) => unknown; + // Specifics + hasReglock: boolean; + triesRemaining: number; +}): JSX.Element { + return ( + <AxoAlertDialog.Root + open={isOpen} + onOpenChange={open => { + if (!open) { + setOpen(false); + } + }} + > + <AxoAlertDialog.Content escape="cancel-is-noop"> + <AxoAlertDialog.Body> + <AxoAlertDialog.Title> + <div className={tw('text-start')}> + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--header' + )} + </div> + </AxoAlertDialog.Title> + <AxoAlertDialog.Description> + <div className={tw('text-start')}> + {hasReglock ? ( + <I18n + i18n={i18n} + id="icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--description--reglock" + components={{ learnMore, triesRemaining }} + /> + ) : ( + <I18n + i18n={i18n} + id="icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--description--no-reglock" + components={{ learnMore, triesRemaining }} + /> + )} + </div> + </AxoAlertDialog.Description> + </AxoAlertDialog.Body> + <AxoAlertDialog.Footer forceAlwaysBreakToSeparateLines> + <AxoAlertDialog.Action + variant="strong-primary" + onClick={() => setOpen(false)} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--FewRemainingTries--button' + )} + </AxoAlertDialog.Action> + </AxoAlertDialog.Footer> + </AxoAlertDialog.Content> + </AxoAlertDialog.Root> + ); +} + +function NoRemainingTriesDialog({ + // Housekeeping + i18n, + isOpen, + setOpen, + // Specifics + goToCreatePINStage, +}: { + // Housekeeping + i18n: LocalizerType; + isOpen: boolean; + setOpen: (value: boolean) => unknown; + // Specifics + goToCreatePINStage: ActionCreator<typeof doGoToCreatePINStage>; +}): JSX.Element { + return ( + <AxoAlertDialog.Root + open={isOpen} + onOpenChange={open => { + if (!open) { + setOpen(false); + } + }} + > + <AxoAlertDialog.Content escape="cancel-is-noop"> + <AxoAlertDialog.Body> + <AxoAlertDialog.Title> + <div className={tw('text-start')}> + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--header' + )} + </div> + </AxoAlertDialog.Title> + <AxoAlertDialog.Description> + <div className={tw('text-start')}> + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--description' + )} + </div> + </AxoAlertDialog.Description> + </AxoAlertDialog.Body> + <AxoAlertDialog.Footer forceAlwaysBreakToSeparateLines> + <AxoAlertDialog.Action + variant="strong-secondary" + onClick={() => openLinkInWebBrowser(PIN_ARTICLE_ON_SUPPORT)} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--learn-more' + )} + </AxoAlertDialog.Action> + <AxoAlertDialog.Action + variant="strong-primary" + onClick={() => goToCreatePINStage()} + > + {i18n( + 'icu:StandaloneRegistration--VerifyPIN--NoRemainingTries--create-new-pin' + )} + </AxoAlertDialog.Action> + </AxoAlertDialog.Footer> + </AxoAlertDialog.Content> + </AxoAlertDialog.Root> ); } diff --git a/ts/components/standaloneRegistration/util/StepComponents.dom.tsx b/ts/components/standaloneRegistration/util/StepComponents.dom.tsx index 44eedb5adc..79f0adb254 100644 --- a/ts/components/standaloneRegistration/util/StepComponents.dom.tsx +++ b/ts/components/standaloneRegistration/util/StepComponents.dom.tsx @@ -9,6 +9,9 @@ import { tw } from '../../../axo/tw.dom.tsx'; import type { LocalizerType } from '../../../types/I18N.std.ts'; import { AxoIconButton } from '../../../axo/AxoIconButton.dom.tsx'; +export const PIN_ARTICLE_ON_SUPPORT = + 'https://support.signal.org/hc/articles/360007059792-Signal-PIN'; + export function Container({ children, className, @@ -56,12 +59,22 @@ export function TopMatter({ export function InputContainer({ children, + className, + helperElement, }: { children: ReactNode; + className?: string; + helperElement?: ReactNode; }): JSX.Element { return ( - <div className={tw('flex min-h-fit w-full flex-col items-center')}> + <div + className={classNames( + tw('flex min-h-fit max-w-full flex-col items-center'), + className + )} + > {children} + {helperElement} </div> ); } diff --git a/ts/state/ducks/standaloneInstaller.preload.ts b/ts/state/ducks/standaloneInstaller.preload.ts index 06c2829abb..136e5008d1 100644 --- a/ts/state/ducks/standaloneInstaller.preload.ts +++ b/ts/state/ducks/standaloneInstaller.preload.ts @@ -795,21 +795,14 @@ export function verifyPIN({ }; dispatch(updateWorkflow(workflow)); } else if (error === 'missing') { - log.warn(`${logId}: Nothing in SVR for this user!`); + log.error(`${logId}: Nothing in SVR for this user!`); if (dataForReglockAccountCreate) { // Something has really gone wrong - we're in reglock, but SVR has nothing for us dispatch(updateWorkflow(workflow, FatalErrorType.UNEXPECTED)); } else { // No reglock and nothing in SVR - let's allow the user to create a new PIN - workflow = { - ...workflow, - status: { - type: 'failed', - error: 'nothing-in-svr', - }, - }; - dispatch(updateWorkflow(workflow)); + dispatch(goToCreatePINStage()); } } else { const unknownError: never = error; diff --git a/ts/test-mock/standalone/registration_test.node.ts b/ts/test-mock/standalone/registration_test.node.ts index 93304e68c4..6a3fb26d20 100644 --- a/ts/test-mock/standalone/registration_test.node.ts +++ b/ts/test-mock/standalone/registration_test.node.ts @@ -9,6 +9,7 @@ import { Bootstrap } from '../bootstrap.node.ts'; import { MINUTE } from '../../util/durations/index.std.ts'; import { typeIntoInput } from '../helpers.node.ts'; import { assert } from 'chai'; +import { randomBytes } from 'node:crypto'; export const debug = createDebug('mock:test:registration'); @@ -34,9 +35,7 @@ describe('registration', function (this: Mocha.Suite) { await bootstrap.teardown(); }); - it('should create totally new account, creating a new PIN with SVR2', async () => { - this.timeout(10000); - + it('should create new account, creating a new PIN with SVR2', async () => { const window = await app.getWindow(); { @@ -71,9 +70,9 @@ describe('registration', function (this: Mocha.Suite) { { debug('VERIFICATION_CODE: enter code'); - const code = '111111'; - for (let i = 0; i < code.length; i += 1) { - const char = code[i]; + const CODE = '111111'; + for (let i = 0; i < CODE.length; i += 1) { + const char = CODE[i]; if (!char) { continue; } @@ -90,32 +89,151 @@ describe('registration', function (this: Mocha.Suite) { debug('PROFILE_ENTRY: enter first name'); const firstNameInput = window.getByPlaceholder('First name (required)'); await typeIntoInput(firstNameInput, 'John', ''); - } - { - debug('PROFILE_ENTRY: complete'); await window.getByRole('button', { name: 'Continue' }).click(); } - const pin = '876543'; + const PIN = '876543'; { debug('CREATE_PIN: enter pin'); const phoneInput = window.getByPlaceholder('Create your PIN'); - await typeIntoInput(phoneInput, pin, ''); + await typeIntoInput(phoneInput, PIN, ''); + await window.getByRole('button', { name: 'Continue' }).click(); } { debug('CREATE_PIN_CONFIRM: enter pin again'); const phoneInput = window.getByPlaceholder('Enter your PIN'); - await typeIntoInput(phoneInput, pin, ''); + await typeIntoInput(phoneInput, PIN, ''); + await window.getByRole('button', { name: 'Continue' }).click(); } - const storedData = await app.getSvr2StoreParameters(); - assert.strictEqual(storedData?.pin, pin); + { + debug('COMPLETE: Verify data was stored in SVR'); + const storedData = await app.getSvr2StoreParameters(); + assert.strictEqual(storedData?.pin, PIN); + } - await expect(window.getByText('Welcome to Signal')).toBeVisible(); + { + debug('COMPLETE: verify welcome screen'); + await expect(window.getByText('Welcome to Signal')).toBeVisible(); + } + }); + + it('should reregister account, verifying PIN with SVR2', async () => { + const window = await app.getWindow(); + const { server } = bootstrap; + + { + debug('PHONE_NUMBER: Enter phone number'); + const phoneInput = window.getByPlaceholder('Phone number'); + await typeIntoInput(phoneInput, '+14155551111', ''); + await window.getByRole('button', { name: 'Continue' }).click(); + } + + { + debug('PHONE_NUMBER: Confirm phone number'); + const dialogText = window.getByText( + 'Is your phone number above correct?' + ); + await expect(dialogText).toBeVisible(); + + await window.getByRole('button', { name: 'Yes' }).click(); + } + + { + debug('CAPTCHA: kick off validation'); + await window.getByRole('button', { name: 'Verify in Browser' }).click(); + } + + { + debug('CAPTCHA: complete validation'); + const { seq, reason } = await app.waitForChallenge(); + assert.strictEqual(reason, 'standalone registration'); + + await app.solveChallenge({ seq, data: { captcha: 'unused' } }); + } + + { + debug('VERIFICATION_CODE: enter code'); + const CODE = '111111'; + for (let i = 0; i < CODE.length; i += 1) { + const char = CODE[i]; + if (!char) { + continue; + } + + const codeInput = window.getByLabel(`Character ${i + 1} of 6`); + // oxlint-disable-next-line no-await-in-loop + await typeIntoInput(codeInput, char, ''); + } + + // Force server to return storageCapable: true + server.setRegisterResponseData({ storageCapable: true }); + + await window.getByRole('button', { name: 'Continue' }).click(); + } + + { + debug('PROFILE_ENTRY: enter first name'); + const firstNameInput = window.getByPlaceholder('First name (required)'); + await typeIntoInput(firstNameInput, 'John', ''); + + await window.getByRole('button', { name: 'Continue' }).click(); + } + + { + debug('VERIFY_PIN: enter incorrect PIN'); + + const INCORRECT_PIN = '123456'; + + const phoneInput = window.getByPlaceholder('Enter your PIN'); + await typeIntoInput(phoneInput, INCORRECT_PIN, ''); + + await app.saveSVR2RestoreResponse({ + success: false, + error: 'pin-incorrect', + triesRemaining: 3, + }); + + await window.getByRole('button', { name: 'Continue' }).click(); + } + + { + debug('VERIFY_PIN: dismiss dialog'); + await expect( + window.getByText('You have 3 attempts remaining') + ).toBeVisible(); + + await window.getByRole('button', { name: 'OK' }).click(); + } + + { + debug('VERIFY_PIN: enter correct PIN'); + + const CORRECT_PIN = '876543'; + const DATA = randomBytes(32); + + const phoneInput = window.getByPlaceholder('Enter your PIN'); + + await phoneInput.clear(); + await typeIntoInput(phoneInput, CORRECT_PIN, ''); + + await app.saveSVR2RestoreResponse({ + success: true, + data: DATA, + triesRemaining: 3, + }); + + await window.getByRole('button', { name: 'Continue' }).click(); + } + + { + debug('COMPLETE: verify welcome screen'); + await expect(window.getByText('Welcome to Signal')).toBeVisible(); + } }); }); diff --git a/ts/types/StandaloneRegistration.std.ts b/ts/types/StandaloneRegistration.std.ts index 0542140f0b..598b09579a 100644 --- a/ts/types/StandaloneRegistration.std.ts +++ b/ts/types/StandaloneRegistration.std.ts @@ -312,7 +312,7 @@ export type VerifyPINStage = { | { type: 'in-progress'; } - | { type: 'failed'; error: 'nothing-in-svr' }; + | { type: 'failed'; error: 'incorrect-pin' }; // After VERIFY_PIN: // - save PIN in local storage