Implement captcha (#2882)

* web height adjustment

border radius incase of dark/dim mismatch

rm country codes

adjust height

general form refactor

more form refactor

refactor form submission

activity indicator after finished

remove remaining phone stuff

adjust captcha height

adjust state to reflect switch

move handle to the second step

pass color scheme param

ts

ts

update state when captcha is complete

web views and callbacks

remove old state

allow specified hosts

replace phone verification with a webview

* remove log

* height adjustment

* few changes

* use the correct url

* remove some debug

* validate handle before continuing

* explicitly check if there is a did, dont rely on error

* rm throw

* update allowed hosts

* update redirect host for webview

* fix handle

* fix handle check

* adjust height for full challenge
zio/stable
Hailey 2024-02-17 16:03:47 -08:00 committed by GitHub
parent dc143d6a6e
commit fbdf4517c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 441 additions and 793 deletions

View File

@ -123,7 +123,6 @@
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"libphonenumber-js": "^1.10.53",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@ -137,7 +136,7 @@
"mobx": "^6.6.1", "mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0", "mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6", "mobx-utils": "^6.0.6",
"nanoid": "^5.0.2", "nanoid": "^5.0.5",
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
"patch-package": "^6.5.1", "patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
@ -164,6 +163,7 @@
"react-native-safe-area-context": "4.8.2", "react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0", "react-native-screens": "~3.29.0",
"react-native-svg": "14.1.0", "react-native-svg": "14.1.0",
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view",
"react-native-url-polyfill": "^1.3.0", "react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1", "react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6", "react-native-version-number": "^0.3.6",
@ -178,8 +178,7 @@
"tlds": "^1.234.0", "tlds": "^1.234.0",
"use-deep-compare": "^1.1.0", "use-deep-compare": "^1.1.0",
"zeego": "^1.6.2", "zeego": "^1.6.2",
"zod": "^3.20.2", "zod": "^3.20.2"
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.2.28", "@atproto/dev-env": "^0.2.28",

View File

@ -1,256 +0,0 @@
import {CountryCode} from 'libphonenumber-js'
// ISO 3166-1 alpha-2 codes
export interface CountryCodeMap {
code2: CountryCode
name: string
}
export const COUNTRY_CODES: CountryCodeMap[] = [
{code2: 'AF', name: 'Afghanistan (+93)'},
{code2: 'AX', name: 'Åland Islands (+358)'},
{code2: 'AL', name: 'Albania (+355)'},
{code2: 'DZ', name: 'Algeria (+213)'},
{code2: 'AS', name: 'American Samoa (+1)'},
{code2: 'AD', name: 'Andorra (+376)'},
{code2: 'AO', name: 'Angola (+244)'},
{code2: 'AI', name: 'Anguilla (+1)'},
{code2: 'AG', name: 'Antigua and Barbuda (+1)'},
{code2: 'AR', name: 'Argentina (+54)'},
{code2: 'AM', name: 'Armenia (+374)'},
{code2: 'AW', name: 'Aruba (+297)'},
{code2: 'AU', name: 'Australia (+61)'},
{code2: 'AT', name: 'Austria (+43)'},
{code2: 'AZ', name: 'Azerbaijan (+994)'},
{code2: 'BS', name: 'Bahamas (+1)'},
{code2: 'BH', name: 'Bahrain (+973)'},
{code2: 'BD', name: 'Bangladesh (+880)'},
{code2: 'BB', name: 'Barbados (+1)'},
{code2: 'BY', name: 'Belarus (+375)'},
{code2: 'BE', name: 'Belgium (+32)'},
{code2: 'BZ', name: 'Belize (+501)'},
{code2: 'BJ', name: 'Benin (+229)'},
{code2: 'BM', name: 'Bermuda (+1)'},
{code2: 'BT', name: 'Bhutan (+975)'},
{code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
{code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
{code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
{code2: 'BW', name: 'Botswana (+267)'},
{code2: 'BR', name: 'Brazil (+55)'},
{code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
{code2: 'BN', name: 'Brunei Darussalam (+673)'},
{code2: 'BG', name: 'Bulgaria (+359)'},
{code2: 'BF', name: 'Burkina Faso (+226)'},
{code2: 'BI', name: 'Burundi (+257)'},
{code2: 'CV', name: 'Cabo Verde (+238)'},
{code2: 'KH', name: 'Cambodia (+855)'},
{code2: 'CM', name: 'Cameroon (+237)'},
{code2: 'CA', name: 'Canada (+1)'},
{code2: 'KY', name: 'Cayman Islands (+1)'},
{code2: 'CF', name: 'Central African Republic (+236)'},
{code2: 'TD', name: 'Chad (+235)'},
{code2: 'CL', name: 'Chile (+56)'},
{code2: 'CN', name: 'China (+86)'},
{code2: 'CX', name: 'Christmas Island (+61)'},
{code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
{code2: 'CO', name: 'Colombia (+57)'},
{code2: 'KM', name: 'Comoros (+269)'},
{code2: 'CG', name: 'Congo (+242)'},
{code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
{code2: 'CK', name: 'Cook Islands (+682)'},
{code2: 'CR', name: 'Costa Rica (+506)'},
{code2: 'CI', name: "Côte d'Ivoire (+225)"},
{code2: 'HR', name: 'Croatia (+385)'},
{code2: 'CU', name: 'Cuba (+53)'},
{code2: 'CW', name: 'Curaçao (+599)'},
{code2: 'CY', name: 'Cyprus (+357)'},
{code2: 'CZ', name: 'Czechia (+420)'},
{code2: 'DK', name: 'Denmark (+45)'},
{code2: 'DJ', name: 'Djibouti (+253)'},
{code2: 'DM', name: 'Dominica (+1)'},
{code2: 'DO', name: 'Dominican Republic (+1)'},
{code2: 'EC', name: 'Ecuador (+593)'},
{code2: 'EG', name: 'Egypt (+20)'},
{code2: 'SV', name: 'El Salvador (+503)'},
{code2: 'GQ', name: 'Equatorial Guinea (+240)'},
{code2: 'ER', name: 'Eritrea (+291)'},
{code2: 'EE', name: 'Estonia (+372)'},
{code2: 'SZ', name: 'Eswatini (+268)'},
{code2: 'ET', name: 'Ethiopia (+251)'},
{code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
{code2: 'FO', name: 'Faroe Islands (+298)'},
{code2: 'FJ', name: 'Fiji (+679)'},
{code2: 'FI', name: 'Finland (+358)'},
{code2: 'FR', name: 'France (+33)'},
{code2: 'GF', name: 'French Guiana (+594)'},
{code2: 'PF', name: 'French Polynesia (+689)'},
{code2: 'GA', name: 'Gabon (+241)'},
{code2: 'GM', name: 'Gambia (+220)'},
{code2: 'GE', name: 'Georgia (+995)'},
{code2: 'DE', name: 'Germany (+49)'},
{code2: 'GH', name: 'Ghana (+233)'},
{code2: 'GI', name: 'Gibraltar (+350)'},
{code2: 'GR', name: 'Greece (+30)'},
{code2: 'GL', name: 'Greenland (+299)'},
{code2: 'GD', name: 'Grenada (+1)'},
{code2: 'GP', name: 'Guadeloupe (+590)'},
{code2: 'GU', name: 'Guam (+1)'},
{code2: 'GT', name: 'Guatemala (+502)'},
{code2: 'GG', name: 'Guernsey (+44)'},
{code2: 'GN', name: 'Guinea (+224)'},
{code2: 'GW', name: 'Guinea-Bissau (+245)'},
{code2: 'GY', name: 'Guyana (+592)'},
{code2: 'HT', name: 'Haiti (+509)'},
{code2: 'VA', name: 'Holy See (+39)'},
{code2: 'HN', name: 'Honduras (+504)'},
{code2: 'HK', name: 'Hong Kong (+852)'},
{code2: 'HU', name: 'Hungary (+36)'},
{code2: 'IS', name: 'Iceland (+354)'},
{code2: 'IN', name: 'India (+91)'},
{code2: 'ID', name: 'Indonesia (+62)'},
{code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
{code2: 'IQ', name: 'Iraq (+964)'},
{code2: 'IE', name: 'Ireland (+353)'},
{code2: 'IM', name: 'Isle of Man (+44)'},
{code2: 'IL', name: 'Israel (+972)'},
{code2: 'IT', name: 'Italy (+39)'},
{code2: 'JM', name: 'Jamaica (+1)'},
{code2: 'JP', name: 'Japan (+81)'},
{code2: 'JE', name: 'Jersey (+44)'},
{code2: 'JO', name: 'Jordan (+962)'},
{code2: 'KZ', name: 'Kazakhstan (+7)'},
{code2: 'KE', name: 'Kenya (+254)'},
{code2: 'KI', name: 'Kiribati (+686)'},
{code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
{code2: 'KR', name: 'Korea, Republic of (+82)'},
{code2: 'KW', name: 'Kuwait (+965)'},
{code2: 'KG', name: 'Kyrgyzstan (+996)'},
{code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
{code2: 'LV', name: 'Latvia (+371)'},
{code2: 'LB', name: 'Lebanon (+961)'},
{code2: 'LS', name: 'Lesotho (+266)'},
{code2: 'LR', name: 'Liberia (+231)'},
{code2: 'LY', name: 'Libya (+218)'},
{code2: 'LI', name: 'Liechtenstein (+423)'},
{code2: 'LT', name: 'Lithuania (+370)'},
{code2: 'LU', name: 'Luxembourg (+352)'},
{code2: 'MO', name: 'Macao (+853)'},
{code2: 'MG', name: 'Madagascar (+261)'},
{code2: 'MW', name: 'Malawi (+265)'},
{code2: 'MY', name: 'Malaysia (+60)'},
{code2: 'MV', name: 'Maldives (+960)'},
{code2: 'ML', name: 'Mali (+223)'},
{code2: 'MT', name: 'Malta (+356)'},
{code2: 'MH', name: 'Marshall Islands (+692)'},
{code2: 'MQ', name: 'Martinique (+596)'},
{code2: 'MR', name: 'Mauritania (+222)'},
{code2: 'MU', name: 'Mauritius (+230)'},
{code2: 'YT', name: 'Mayotte (+262)'},
{code2: 'MX', name: 'Mexico (+52)'},
{code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
{code2: 'MD', name: 'Moldova, Republic of (+373)'},
{code2: 'MC', name: 'Monaco (+377)'},
{code2: 'MN', name: 'Mongolia (+976)'},
{code2: 'ME', name: 'Montenegro (+382)'},
{code2: 'MS', name: 'Montserrat (+1)'},
{code2: 'MA', name: 'Morocco (+212)'},
{code2: 'MZ', name: 'Mozambique (+258)'},
{code2: 'MM', name: 'Myanmar (+95)'},
{code2: 'NA', name: 'Namibia (+264)'},
{code2: 'NR', name: 'Nauru (+674)'},
{code2: 'NP', name: 'Nepal (+977)'},
{code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
{code2: 'NC', name: 'New Caledonia (+687)'},
{code2: 'NZ', name: 'New Zealand (+64)'},
{code2: 'NI', name: 'Nicaragua (+505)'},
{code2: 'NE', name: 'Niger (+227)'},
{code2: 'NG', name: 'Nigeria (+234)'},
{code2: 'NU', name: 'Niue (+683)'},
{code2: 'NF', name: 'Norfolk Island (+672)'},
{code2: 'MK', name: 'North Macedonia (+389)'},
{code2: 'MP', name: 'Northern Mariana Islands (+1)'},
{code2: 'NO', name: 'Norway (+47)'},
{code2: 'OM', name: 'Oman (+968)'},
{code2: 'PK', name: 'Pakistan (+92)'},
{code2: 'PW', name: 'Palau (+680)'},
{code2: 'PS', name: 'Palestine, State of (+970)'},
{code2: 'PA', name: 'Panama (+507)'},
{code2: 'PG', name: 'Papua New Guinea (+675)'},
{code2: 'PY', name: 'Paraguay (+595)'},
{code2: 'PE', name: 'Peru (+51)'},
{code2: 'PH', name: 'Philippines (+63)'},
{code2: 'PL', name: 'Poland (+48)'},
{code2: 'PT', name: 'Portugal (+351)'},
{code2: 'PR', name: 'Puerto Rico (+1)'},
{code2: 'QA', name: 'Qatar (+974)'},
{code2: 'RE', name: 'Réunion (+262)'},
{code2: 'RO', name: 'Romania (+40)'},
{code2: 'RU', name: 'Russian Federation (+7)'},
{code2: 'RW', name: 'Rwanda (+250)'},
{code2: 'BL', name: 'Saint Barthélemy (+590)'},
{code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
{code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
{code2: 'LC', name: 'Saint Lucia (+1)'},
{code2: 'MF', name: 'Saint Martin (French part) (+590)'},
{code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
{code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
{code2: 'WS', name: 'Samoa (+685)'},
{code2: 'SM', name: 'San Marino (+378)'},
{code2: 'ST', name: 'Sao Tome and Principe (+239)'},
{code2: 'SA', name: 'Saudi Arabia (+966)'},
{code2: 'SN', name: 'Senegal (+221)'},
{code2: 'RS', name: 'Serbia (+381)'},
{code2: 'SC', name: 'Seychelles (+248)'},
{code2: 'SL', name: 'Sierra Leone (+232)'},
{code2: 'SG', name: 'Singapore (+65)'},
{code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
{code2: 'SK', name: 'Slovakia (+421)'},
{code2: 'SI', name: 'Slovenia (+386)'},
{code2: 'SB', name: 'Solomon Islands (+677)'},
{code2: 'SO', name: 'Somalia (+252)'},
{code2: 'ZA', name: 'South Africa (+27)'},
{code2: 'SS', name: 'South Sudan (+211)'},
{code2: 'ES', name: 'Spain (+34)'},
{code2: 'LK', name: 'Sri Lanka (+94)'},
{code2: 'SD', name: 'Sudan (+249)'},
{code2: 'SR', name: 'Suriname (+597)'},
{code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
{code2: 'SE', name: 'Sweden (+46)'},
{code2: 'CH', name: 'Switzerland (+41)'},
{code2: 'SY', name: 'Syrian Arab Republic (+963)'},
{code2: 'TW', name: 'Taiwan (+886)'},
{code2: 'TJ', name: 'Tajikistan (+992)'},
{code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
{code2: 'TH', name: 'Thailand (+66)'},
{code2: 'TL', name: 'Timor-Leste (+670)'},
{code2: 'TG', name: 'Togo (+228)'},
{code2: 'TK', name: 'Tokelau (+690)'},
{code2: 'TO', name: 'Tonga (+676)'},
{code2: 'TT', name: 'Trinidad and Tobago (+1)'},
{code2: 'TN', name: 'Tunisia (+216)'},
{code2: 'TR', name: 'Türkiye (+90)'},
{code2: 'TM', name: 'Turkmenistan (+993)'},
{code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
{code2: 'TV', name: 'Tuvalu (+688)'},
{code2: 'UG', name: 'Uganda (+256)'},
{code2: 'UA', name: 'Ukraine (+380)'},
{code2: 'AE', name: 'United Arab Emirates (+971)'},
{
code2: 'GB',
name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
},
{code2: 'US', name: 'United States of America (+1)'},
{code2: 'UY', name: 'Uruguay (+598)'},
{code2: 'UZ', name: 'Uzbekistan (+998)'},
{code2: 'VU', name: 'Vanuatu (+678)'},
{code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
{code2: 'VN', name: 'Viet Nam (+84)'},
{code2: 'VG', name: 'Virgin Islands (British) (+1)'},
{code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
{code2: 'WF', name: 'Wallis and Futuna (+681)'},
{code2: 'EH', name: 'Western Sahara (+212)'},
{code2: 'YE', name: 'Yemen (+967)'},
{code2: 'ZM', name: 'Zambia (+260)'},
{code2: 'ZW', name: 'Zimbabwe (+263)'},
]

View File

@ -0,0 +1,86 @@
import React from 'react'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {StyleSheet} from 'react-native'
import {CreateAccountState} from 'view/com/auth/create/state'
const ALLOWED_HOSTS = [
'bsky.social',
'bsky.app',
'staging.bsky.app',
'staging.bsky.dev',
'js.hcaptcha.com',
'newassets.hcaptcha.com',
'api2.hcaptcha.com',
]
export function CaptchaWebView({
url,
stateParam,
uiState,
onSuccess,
onError,
}: {
url: string
stateParam: string
uiState?: CreateAccountState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!uiState?.serviceUrl) return 'bsky.app'
return uiState?.serviceUrl &&
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
}, [uiState?.serviceUrl])
const wasSuccessful = React.useRef(false)
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return ALLOWED_HOSTS.includes(urlp.host)
},
[],
)
const onNavigationStateChange = React.useCallback(
(e: WebViewNavigation) => {
if (wasSuccessful.current) return
const urlp = new URL(e.url)
if (urlp.host !== redirectHost) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
wasSuccessful.current = true
onSuccess(code)
},
[redirectHost, stateParam, onSuccess, onError],
)
return (
<WebView
source={{uri: url}}
javaScriptEnabled
style={styles.webview}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
/>
)
}
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: 'transparent',
borderRadius: 10,
},
})

View File

@ -0,0 +1,61 @@
import React from 'react'
import {StyleSheet} from 'react-native'
// @ts-ignore web only, we will always redirect to the app on web (CORS)
const REDIRECT_HOST = new URL(window.location.href).host
export function CaptchaWebView({
url,
stateParam,
onSuccess,
onError,
}: {
url: string
stateParam: string
onSuccess: (code: string) => void
onError: () => void
}) {
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
'captcha-iframe',
) as HTMLIFrameElement
try {
// @ts-ignore web
const href = frame?.contentWindow?.location.href
if (!href) return
const urlp = new URL(href)
// This shouldn't happen with CORS protections, but for good measure
if (urlp.host !== REDIRECT_HOST) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
onSuccess(code)
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
return (
<iframe
src={url}
style={styles.iframe}
id="captcha-iframe"
onLoad={onLoad}
/>
)
}
const styles = StyleSheet.create({
iframe: {
flex: 1,
borderWidth: 0,
borderRadius: 10,
backgroundColor: 'transparent',
},
})

View File

@ -13,33 +13,25 @@ import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useOnboardingDispatch} from '#/state/shell' import {useCreateAccount, useSubmitCreateAccount} from './state'
import {useSessionApi} from '#/state/session'
import {useCreateAccount, submit} from './state'
import {useServiceQuery} from '#/state/queries/service' import {useServiceQuery} from '#/state/queries/service'
import { import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
import {Step1} from './Step1' import {Step1} from './Step1'
import {Step2} from './Step2' import {Step2} from './Step2'
import {Step3} from './Step3' import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link' import {TextLink} from '../../util/Link'
import {getAgent} from 'state/session'
import {createFullHandle} from 'lib/strings/handles'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) { export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics() const {screen} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const [uiState, uiDispatch] = useCreateAccount() const [uiState, uiDispatch] = useCreateAccount()
const onboardingDispatch = useOnboardingDispatch()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
React.useEffect(() => { React.useEffect(() => {
screen('CreateAccount') screen('CreateAccount')
@ -84,33 +76,48 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
if (!uiState.canNext) { if (!uiState.canNext) {
return return
} }
if (uiState.step < 3) {
uiDispatch({type: 'next'}) if (uiState.step === 2) {
} else { uiDispatch({type: 'set-processing', value: true})
try { try {
await submit({ const res = await getAgent().resolveHandle({
onboardingDispatch, handle: createFullHandle(uiState.handle, uiState.userDomain),
createAccount,
uiState,
uiDispatch,
_,
}) })
setBirthDate({birthDate: uiState.birthDate})
if (IS_PROD(uiState.serviceUrl)) { if (res.data.did) {
setSavedFeeds(DEFAULT_PROD_FEEDS) uiDispatch({
type: 'set-error',
value: _(msg`That handle is already taken.`),
})
return
} }
} catch { } catch (e) {
// dont need to handle here // Don't need to handle
} finally {
uiDispatch({type: 'set-processing', value: false})
}
if (!uiState.isCaptchaRequired) {
try {
await submit()
} catch {
// dont need to handle here
}
// We don't need to go to the next page if there wasn't a captcha required
return
} }
} }
uiDispatch({type: 'next'})
}, [ }, [
uiState, uiState.canNext,
uiState.step,
uiState.isCaptchaRequired,
uiState.handle,
uiState.userDomain,
uiDispatch, uiDispatch,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
_, _,
submit,
]) ])
// rendering // rendering

View File

@ -73,6 +73,10 @@ export function Step1({
/> />
<StepHeader uiState={uiState} title={_(msg`Your account`)} /> <StepHeader uiState={uiState} title={_(msg`Your account`)} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<View style={s.pb20}> <View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}> <Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Hosting provider</Trans> <Trans>Hosting provider</Trans>
@ -259,9 +263,6 @@ export function Step1({
)} )}
</> </>
)} )}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View> </View>
) )
} }
@ -269,7 +270,7 @@ export function Step1({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
error: { error: {
borderRadius: 6, borderRadius: 6,
marginTop: 10, marginBottom: 10,
}, },
dateInputButton: { dateInputButton: {
borderWidth: 1, borderWidth: 1,

View File

@ -1,35 +1,19 @@
import React from 'react' import React from 'react'
import { import {StyleSheet, View} from 'react-native'
ActivityIndicator, import {CreateAccountState, CreateAccountDispatch} from './state'
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {
CreateAccountState,
CreateAccountDispatch,
requestVerificationCode,
} from './state'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput' import {TextInput} from '../util/TextInput'
import {Button} from '../../util/forms/Button' import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isAndroid, isWeb} from 'platform/detection' import {msg, Trans} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import parsePhoneNumber from 'libphonenumber-js'
import {COUNTRY_CODES} from '#/lib/country-codes'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {HITSLOP_10} from '#/lib/constants'
/** STEP 3: Your user handle
* @field User handle
*/
export function Step2({ export function Step2({
uiState, uiState,
uiDispatch, uiDispatch,
@ -39,258 +23,34 @@ export function Step2({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const onPressRequest = React.useCallback(() => {
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)
if (phoneNumber && phoneNumber.isValid()) {
requestVerificationCode({uiState, uiDispatch, _})
} else {
uiDispatch({
type: 'set-error',
value: _(
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
),
})
}
}, [uiState, uiDispatch, _])
const onPressRetry = React.useCallback(() => {
uiDispatch({type: 'set-has-requested-verification-code', value: false})
}, [uiDispatch])
const phoneNumberFormatted = React.useMemo(
() =>
uiState.hasRequestedVerificationCode
? parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.formatInternational()
: '',
[
uiState.hasRequestedVerificationCode,
uiState.verificationPhone,
uiState.phoneCountry,
],
)
return ( return (
<View> <View>
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
{!uiState.hasRequestedVerificationCode ? (
<>
<View style={s.pb10}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneCountry">
<Trans>Country</Trans>
</Text>
<View
style={[
{position: 'relative'},
isAndroid && {
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
]}>
<RNPickerSelect
placeholder={{}}
value={uiState.phoneCountry}
onValueChange={value =>
uiDispatch({type: 'set-phone-country', value})
}
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 21,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
inputIOS: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
inputWeb: {
// @ts-ignore web only
cursor: 'pointer',
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 1,
borderColor: pal.border.borderColor,
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
}}
accessibilityLabel={_(msg`Select your phone's country`)}
accessibilityHint=""
accessibilityLabelledBy="phoneCountry"
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</View>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneNumber">
<Trans>Phone number</Trans>
</Text>
<TextInput
testID="phoneInput"
icon="phone"
placeholder={_(msg`Enter your phone number`)}
value={uiState.verificationPhone}
editable
onChange={value =>
uiDispatch({type: 'set-verification-phone', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input phone number for SMS verification`,
)}
accessibilityLabelledBy="phoneNumber"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="tel"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter a phone number that can receive SMS text messages.
</Trans>
</Text>
</View>
<View style={isMobile ? {} : {flexDirection: 'row'}}>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Button
testID="requestCodeBtn"
type="primary"
label={_(msg`Request code`)}
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
style={
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
}
onPress={onPressRequest}
/>
)}
</View>
</>
) : (
<>
<View style={s.pb20}>
<View
style={[
s.flexRow,
s.mb5,
s.alignCenter,
{justifyContent: 'space-between'},
]}>
<Text
type="md-medium"
style={pal.text}
nativeID="verificationCode">
<Trans>Verification code</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressRetry}
accessibilityLabel={_(msg`Retry.`)}
accessibilityHint=""
hitSlop={HITSLOP_10}>
<View style={styles.touchable}>
<Text
type="md-medium"
style={pal.link}
nativeID="verificationCode">
<Trans>Retry</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<TextInput
testID="codeInput"
icon="hashtag"
placeholder={_(msg`XXXXXX`)}
value={uiState.verificationCode}
editable
onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter the verification code sent to{' '}
{phoneNumberFormatted}.
</Trans>
</Text>
</View>
</>
)}
{uiState.error ? ( {uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} /> <ErrorMessage message={uiState.error} style={styles.error} />
) : undefined} ) : undefined}
<View style={s.pb10}>
<TextInput
testID="handleInput"
icon="at"
placeholder="e.g. alice"
value={uiState.handle}
editable
autoFocus
autoComplete="off"
autoCorrect={false}
onChange={value => uiDispatch({type: 'set-handle', value})}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text>
</Text>
</View>
</View> </View>
) )
} }
@ -298,10 +58,6 @@ export function Step2({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
error: { error: {
borderRadius: 6, borderRadius: 6,
marginTop: 10, marginBottom: 10,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
}, },
}) })

View File

@ -1,19 +1,23 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state' import {
import {Text} from 'view/com/util/text/Text' CreateAccountState,
CreateAccountDispatch,
useSubmitCreateAccount,
} from './state'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput'
import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro' import {isWeb} from 'platform/detection'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
/** STEP 3: Your user handle import {nanoid} from 'nanoid/non-secure'
* @field User handle import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
*/ import {useTheme} from 'lib/ThemeContext'
import {createFullHandle} from 'lib/strings/handles'
const CAPTCHA_PATH = '/gate/signup'
export function Step3({ export function Step3({
uiState, uiState,
uiDispatch, uiDispatch,
@ -21,33 +25,66 @@ export function Step3({
uiState: CreateAccountState uiState: CreateAccountState
uiDispatch: CreateAccountDispatch uiDispatch: CreateAccountDispatch
}) { }) {
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const theme = useTheme()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
const [completed, setCompleted] = React.useState(false)
const stateParam = React.useMemo(() => nanoid(15), [])
const url = React.useMemo(() => {
const newUrl = new URL(uiState.serviceUrl)
newUrl.pathname = CAPTCHA_PATH
newUrl.searchParams.set(
'handle',
createFullHandle(uiState.handle, uiState.userDomain),
)
newUrl.searchParams.set('state', stateParam)
newUrl.searchParams.set('colorScheme', theme.colorScheme)
console.log(newUrl)
return newUrl.href
}, [
uiState.serviceUrl,
uiState.handle,
uiState.userDomain,
stateParam,
theme.colorScheme,
])
const onSuccess = React.useCallback(
(code: string) => {
setCompleted(true)
submit(code)
},
[submit],
)
const onError = React.useCallback(() => {
uiDispatch({
type: 'set-error',
value: _(msg`Error receiving captcha response.`),
})
}, [_, uiDispatch])
return ( return (
<View> <View>
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> <StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
<View style={s.pb10}> <View style={[styles.container, completed && styles.center]}>
<TextInput {!completed ? (
testID="handleInput" <CaptchaWebView
icon="at" url={url}
placeholder="e.g. alice" stateParam={stateParam}
value={uiState.handle} uiState={uiState}
editable onSuccess={onSuccess}
autoFocus onError={onError}
autoComplete="off" />
autoCorrect={false} ) : (
onChange={value => uiDispatch({type: 'set-handle', value})} <ActivityIndicator size="large" />
// TODO: Add explicit text label )}
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text>
</Text>
</View> </View>
{uiState.error ? ( {uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} /> <ErrorMessage message={uiState.error} style={styles.error} />
) : undefined} ) : undefined}
@ -58,5 +95,20 @@ export function Step3({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
error: { error: {
borderRadius: 6, borderRadius: 6,
marginTop: 10,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
container: {
minHeight: 500,
width: '100%',
paddingBottom: 20,
overflow: 'hidden',
},
center: {
alignItems: 'center',
justifyContent: 'center',
}, },
}) })

View File

@ -11,7 +11,7 @@ export function StepHeader({
children, children,
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) { }: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
const pal = usePalette('default') const pal = usePalette('default')
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2 const numSteps = 3
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View> <View>

View File

@ -1,8 +1,7 @@
import {useReducer} from 'react' import {useCallback, useReducer} from 'react'
import { import {
ComAtprotoServerDescribeServer, ComAtprotoServerDescribeServer,
ComAtprotoServerCreateAccount, ComAtprotoServerCreateAccount,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {I18nContext, useLingui} from '@lingui/react' import {I18nContext, useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@ -11,10 +10,14 @@ import {getAge} from 'lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {ApiContext as SessionApiContext} from '#/state/session' import {useSessionApi} from '#/state/session'
import {DEFAULT_SERVICE} from '#/lib/constants' import {DEFAULT_SERVICE, IS_PROD} from '#/lib/constants'
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js' import {
DEFAULT_PROD_FEEDS,
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
} from 'state/queries/preferences'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@ -29,10 +32,6 @@ export type CreateAccountAction =
| {type: 'set-invite-code'; value: string} | {type: 'set-invite-code'; value: string}
| {type: 'set-email'; value: string} | {type: 'set-email'; value: string}
| {type: 'set-password'; value: string} | {type: 'set-password'; value: string}
| {type: 'set-phone-country'; value: CountryCode}
| {type: 'set-verification-phone'; value: string}
| {type: 'set-verification-code'; value: string}
| {type: 'set-has-requested-verification-code'; value: boolean}
| {type: 'set-handle'; value: string} | {type: 'set-handle'; value: string}
| {type: 'set-birth-date'; value: Date} | {type: 'set-birth-date'; value: Date}
| {type: 'next'} | {type: 'next'}
@ -49,10 +48,6 @@ export interface CreateAccountState {
inviteCode: string inviteCode: string
email: string email: string
password: string password: string
phoneCountry: CountryCode
verificationPhone: string
verificationCode: string
hasRequestedVerificationCode: boolean
handle: string handle: string
birthDate: Date birthDate: Date
@ -60,13 +55,14 @@ export interface CreateAccountState {
canBack: boolean canBack: boolean
canNext: boolean canNext: boolean
isInviteCodeRequired: boolean isInviteCodeRequired: boolean
isPhoneVerificationRequired: boolean isCaptchaRequired: boolean
} }
export type CreateAccountDispatch = (action: CreateAccountAction) => void export type CreateAccountDispatch = (action: CreateAccountAction) => void
export function useCreateAccount() { export function useCreateAccount() {
const {_} = useLingui() const {_} = useLingui()
return useReducer(createReducer({_}), { return useReducer(createReducer({_}), {
step: 1, step: 1,
error: undefined, error: undefined,
@ -77,144 +73,126 @@ export function useCreateAccount() {
inviteCode: '', inviteCode: '',
email: '', email: '',
password: '', password: '',
phoneCountry: 'US',
verificationPhone: '',
verificationCode: '',
hasRequestedVerificationCode: false,
handle: '', handle: '',
birthDate: DEFAULT_DATE, birthDate: DEFAULT_DATE,
canBack: false, canBack: false,
canNext: false, canNext: false,
isInviteCodeRequired: false, isInviteCodeRequired: false,
isPhoneVerificationRequired: false, isCaptchaRequired: false,
}) })
} }
export async function requestVerificationCode({ export function useSubmitCreateAccount(
uiState, uiState: CreateAccountState,
uiDispatch, uiDispatch: CreateAccountDispatch,
_, ) {
}: { const {_} = useLingui()
uiState: CreateAccountState const {createAccount} = useSessionApi()
uiDispatch: CreateAccountDispatch const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
_: I18nContext['_'] const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
}) { const onboardingDispatch = useOnboardingDispatch()
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.number
if (!phoneNumber) {
return
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
try {
const agent = new BskyAgent({service: uiState.serviceUrl})
await agent.com.atproto.temp.requestPhoneVerification({
phoneNumber,
})
uiDispatch({type: 'set-has-requested-verification-code', value: true})
} catch (e: any) {
logger.error(
`Failed to request sms verification code (${e.status} status)`,
{message: e},
)
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
}
uiDispatch({type: 'set-processing', value: false})
}
export async function submit({ return useCallback(
createAccount, async (verificationCode?: string) => {
onboardingDispatch, if (!uiState.email) {
uiState, uiDispatch({type: 'set-step', value: 1})
uiDispatch, console.log('no email?')
_, return uiDispatch({
}: { type: 'set-error',
createAccount: SessionApiContext['createAccount'] value: _(msg`Please enter your email.`),
onboardingDispatch: OnboardingDispatchContext })
uiState: CreateAccountState }
uiDispatch: CreateAccountDispatch if (!EmailValidator.validate(uiState.email)) {
_: I18nContext['_'] uiDispatch({type: 'set-step', value: 1})
}) { return uiDispatch({
if (!uiState.email) { type: 'set-error',
uiDispatch({type: 'set-step', value: 1}) value: _(msg`Your email appears to be invalid.`),
return uiDispatch({ })
type: 'set-error', }
value: _(msg`Please enter your email.`), if (!uiState.password) {
}) uiDispatch({type: 'set-step', value: 1})
} return uiDispatch({
if (!EmailValidator.validate(uiState.email)) { type: 'set-error',
uiDispatch({type: 'set-step', value: 1}) value: _(msg`Please choose your password.`),
return uiDispatch({ })
type: 'set-error', }
value: _(msg`Your email appears to be invalid.`), if (!uiState.handle) {
}) uiDispatch({type: 'set-step', value: 2})
} return uiDispatch({
if (!uiState.password) { type: 'set-error',
uiDispatch({type: 'set-step', value: 1}) value: _(msg`Please choose your handle.`),
return uiDispatch({ })
type: 'set-error', }
value: _(msg`Please choose your password.`), if (uiState.isCaptchaRequired && !verificationCode) {
}) uiDispatch({type: 'set-step', value: 3})
} return uiDispatch({
if ( type: 'set-error',
uiState.isPhoneVerificationRequired && value: _(msg`Please complete the verification captcha.`),
(!uiState.verificationPhone || !uiState.verificationCode) })
) { }
uiDispatch({type: 'set-step', value: 2}) uiDispatch({type: 'set-error', value: ''})
return uiDispatch({ uiDispatch({type: 'set-processing', value: true})
type: 'set-error',
value: _(msg`Please enter the code you received by SMS.`),
})
}
if (!uiState.handle) {
uiDispatch({type: 'set-step', value: 3})
return uiDispatch({
type: 'set-error',
value: _(msg`Please choose your handle.`),
})
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
try { try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({ await createAccount({
service: uiState.serviceUrl, service: uiState.serviceUrl,
email: uiState.email, email: uiState.email,
handle: createFullHandle(uiState.handle, uiState.userDomain), handle: createFullHandle(uiState.handle, uiState.userDomain),
password: uiState.password, password: uiState.password,
inviteCode: uiState.inviteCode.trim(), inviteCode: uiState.inviteCode.trim(),
verificationPhone: uiState.verificationPhone.trim(), verificationCode: uiState.isCaptchaRequired
verificationCode: uiState.verificationCode.trim(), ? verificationCode
}) : undefined,
} catch (e: any) { })
onboardingDispatch({type: 'skip'}) // undo starting the onboard setBirthDate({birthDate: uiState.birthDate})
let errMsg = e.toString() if (IS_PROD(uiState.serviceUrl)) {
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { setSavedFeeds(DEFAULT_PROD_FEEDS)
errMsg = _( }
msg`Invite code not accepted. Check that you input it correctly and try again.`, } catch (e: any) {
) onboardingDispatch({type: 'skip'}) // undo starting the onboard
uiDispatch({type: 'set-step', value: 1}) let errMsg = e.toString()
} else if (e.error === 'InvalidPhoneVerification') { if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
uiDispatch({type: 'set-step', value: 2}) errMsg = _(
} msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
uiDispatch({type: 'set-step', value: 1})
}
if ([400, 429].includes(e.status)) { if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {message: e}) logger.warn('Failed to create account', {message: e})
} else { } else {
logger.error(`Failed to create account (${e.status} status)`, { logger.error(`Failed to create account (${e.status} status)`, {
message: e, message: e,
}) })
} }
uiDispatch({type: 'set-processing', value: false}) const error = cleanError(errMsg)
uiDispatch({type: 'set-error', value: cleanError(errMsg)}) const isHandleError = error.toLowerCase().includes('handle')
throw e
} uiDispatch({type: 'set-processing', value: false})
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
}
},
[
uiState.email,
uiState.password,
uiState.handle,
uiState.isCaptchaRequired,
uiState.serviceUrl,
uiState.userDomain,
uiState.inviteCode,
uiState.birthDate,
uiDispatch,
_,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
],
)
} }
export function is13(state: CreateAccountState) { export function is13(state: CreateAccountState) {
@ -269,22 +247,6 @@ function createReducer({_}: {_: I18nContext['_']}) {
case 'set-password': { case 'set-password': {
return compute({...state, password: action.value}) return compute({...state, password: action.value})
} }
case 'set-phone-country': {
return compute({...state, phoneCountry: action.value})
}
case 'set-verification-phone': {
return compute({
...state,
verificationPhone: action.value,
hasRequestedVerificationCode: false,
})
}
case 'set-verification-code': {
return compute({...state, verificationCode: action.value.trim()})
}
case 'set-has-requested-verification-code': {
return compute({...state, hasRequestedVerificationCode: action.value})
}
case 'set-handle': { case 'set-handle': {
return compute({...state, handle: action.value}) return compute({...state, handle: action.value})
} }
@ -302,18 +264,10 @@ function createReducer({_}: {_: I18nContext['_']}) {
}) })
} }
} }
let increment = 1 return compute({...state, error: '', step: state.step + 1})
if (state.step === 1 && !state.isPhoneVerificationRequired) {
increment = 2
}
return compute({...state, error: '', step: state.step + increment})
} }
case 'back': { case 'back': {
let decrement = 1 return compute({...state, error: '', step: state.step - 1})
if (state.step === 3 && !state.isPhoneVerificationRequired) {
decrement = 2
}
return compute({...state, error: '', step: state.step - decrement})
} }
} }
} }
@ -328,23 +282,16 @@ function compute(state: CreateAccountState): CreateAccountState {
!!state.email && !!state.email &&
!!state.password !!state.password
} else if (state.step === 2) { } else if (state.step === 2) {
canNext =
!state.isPhoneVerificationRequired ||
(!!state.verificationPhone &&
isValidVerificationCode(state.verificationCode))
} else if (state.step === 3) {
canNext = !!state.handle canNext = !!state.handle
} else if (state.step === 3) {
// Step 3 will automatically redirect as soon as the captcha completes
canNext = false
} }
return { return {
...state, ...state,
canBack: state.step > 1, canBack: state.step > 1,
canNext, canNext,
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
isPhoneVerificationRequired: isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
!!state.serviceDescription?.phoneVerificationRequired,
} }
} }
function isValidVerificationCode(str: string): boolean {
return /[0-9]{6}/.test(str)
}

View File

@ -15158,11 +15158,6 @@ levn@^0.4.1:
prelude-ls "^1.2.1" prelude-ls "^1.2.1"
type-check "~0.4.0" type-check "~0.4.0"
libphonenumber-js@^1.10.53:
version "1.10.53"
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.53.tgz#8dbfe1355ef1a3d8e13b8d92849f7db7ebddc98f"
integrity sha512-sDTnnqlWK4vH4AlDQuswz3n4Hx7bIQWTpIcScJX+Sp7St3LXHmfiax/ZFfyYxHmkdCvydOLSuvtAO/XpXiSySw==
lie@3.1.1: lie@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
@ -16157,10 +16152,10 @@ nanoid@^3.1.23, nanoid@^3.3.1, nanoid@^3.3.6:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
nanoid@^5.0.2: nanoid@^5.0.5:
version "5.0.2" version "5.0.5"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.2.tgz#97588ebc70166d0feaf73ccd2799bb4ceaebf692" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.5.tgz#5112efb5c0caf4fc80680d66d303c65233a79fdd"
integrity sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg== integrity sha512-/Veqm+QKsyMY3kqi4faWplnY1u+VuKO3dD2binyPIybP31DRO29bPF+1mszgLnrR2KqSLceFLBNw0zmvDzN1QQ==
napi-build-utils@^1.0.1: napi-build-utils@^1.0.1:
version "1.0.2" version "1.0.2"