Standalone Registration: Support for Verify PIN scenarios

This commit is contained in:
Scott Nonnenberg
2026-07-22 03:10:11 +10:00
committed by GitHub
parent 94beb29190
commit d00aaf133b
16 changed files with 987 additions and 200 deletions
+9 -1
View File
@@ -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 cant remember your PIN, you can create a new one. You can register and use your account but youll lose some saved settings like your profile information. Learn more.",
"messageformat": "If you cant remember your PIN, you can create a new one. You can register and use your account but youll lose some saved settings like your profile information. <learnMore>Learn more</learnMore>.",
"description": "Description for dialog shown when user chooses to skip PIN entry on the Verify PIN screen"
},
"icu:StandaloneRegistration--VerifyPIN--SkipPIN--skip-button": {
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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
+6
View File
@@ -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'
+3 -1
View File
@@ -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 {
<input
ref={mergedRef}
id={inputId}
type="text"
type={props.type ?? 'text'}
value={props.value}
placeholder={props.placeholder ?? ''}
required={props.required}
@@ -35,6 +35,7 @@ const meta = {
} satisfies Meta<typeof StandaloneRegistration>;
const phoneNumber = '+14155551111';
const verificationSessionId = 'verification-session-id';
export default meta;
type Story = StoryObj<typeof meta>;
@@ -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<unknown>) => {
@@ -647,7 +836,7 @@ export function ProgressionWithVerify(): React.JSX.Element {
status: {
type: 'ready',
},
verificationSessionId: 'verification-session-id',
verificationSessionId,
failedToSendSMS: false,
failedToCall: false,
});
@@ -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 = (
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="implied-secondary" size="md">
<AxoSymbol.Icon
symbol="more"
size={20}
label={i18n('icu:StandaloneRegistration--CreatePIN--open-menu')}
/>
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item
symbol="info"
onSelect={() => {
setIsShowingAboutPINsDialog(true);
}}
>
{i18n('icu:StandaloneRegistration--CreatePIN--menu--more-about')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
symbol="minus-circle"
onSelect={() => {
setIsShowingDisablePINDialog(true);
}}
>
{i18n('icu:StandaloneRegistration--CreatePIN--menu--disable')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
);
return (
<Container>
<TopMatter
i18n={i18n}
rightContent={
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoButton.Root variant="implied-secondary" size="md">
<AxoSymbol.Icon
symbol="more"
size={20}
label={i18n(
'icu:StandaloneRegistration--CreatePIN--open-menu'
)}
/>
</AxoButton.Root>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item
symbol="info"
onSelect={() => {
setIsShowingAboutPINsDialog(true);
}}
>
{i18n(
'icu:StandaloneRegistration--CreatePIN--menu--more-about'
)}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
symbol="minus-circle"
onSelect={() => {
setIsShowingDisablePINDialog(true);
}}
>
{i18n('icu:StandaloneRegistration--CreatePIN--menu--disable')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
}
/>
<TopMatter i18n={i18n} rightContent={topRightMenu} />
<Spacer className={tw('h-13')} />
<Title text={i18n('icu:StandaloneRegistration--CreatePIN--header')} />
<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);
}}
>
@@ -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>
@@ -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>
@@ -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(
@@ -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,
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
+133 -15
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -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