diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/Login/ChooseAccountForm.tsx | 201 | ||||
-rw-r--r-- | src/screens/Login/ForgotPasswordForm.tsx | 183 | ||||
-rw-r--r-- | src/screens/Login/FormContainer.tsx | 53 | ||||
-rw-r--r-- | src/screens/Login/LoginForm.tsx | 265 | ||||
-rw-r--r-- | src/screens/Login/PasswordUpdatedForm.tsx | 49 | ||||
-rw-r--r-- | src/screens/Login/ScreenTransition.tsx | 10 | ||||
-rw-r--r-- | src/screens/Login/ScreenTransition.web.tsx | 1 | ||||
-rw-r--r-- | src/screens/Login/SetNewPasswordForm.tsx | 190 | ||||
-rw-r--r-- | src/screens/Login/index.tsx | 169 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha.tsx | 94 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle.tsx | 134 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo.tsx | 145 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 225 | ||||
-rw-r--r-- | src/screens/Signup/state.ts | 320 |
14 files changed, 2039 insertions, 0 deletions
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx new file mode 100644 index 000000000..dd807ba3a --- /dev/null +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import {View} from 'react-native' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {useAnalytics} from 'lib/analytics/analytics' +import {UserAvatar} from '../../view/com/util/UserAvatar' +import {colors} from 'lib/styles' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' +import {Button} from '#/components/Button' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import * as TextField from '#/components/forms/TextField' +import {FormContainer} from './FormContainer' + +function Group({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <View + style={[ + a.rounded_md, + a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, + ]}> + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View style={[a.border_b, t.atoms.border_contrast_low]} /> + ) : null} + {child} + </React.Fragment> + ) : null + })} + </View> + ) +} + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + <Button + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[a.flex_1]} + onPress={onPress} + label={ + isCurrentAccount + ? _(msg`Continue as ${account.handle} (currently signed in)`) + : _(msg`Sign in as ${account.handle}`) + }> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + {height: 48}, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <View style={a.p_md}> + <UserAvatar avatar={profile?.avatar} size={24} /> + </View> + <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}> + <Text style={[a.font_bold]}> + {profile?.displayName || account.handle}{' '} + </Text> + <Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text> + </Text> + {isCurrentAccount ? ( + <Check size="sm" style={[{color: colors.green3}, a.mr_md]} /> + ) : ( + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + )} + </View> + )} + </Button> + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const {_} = useLingui() + const t = useTheme() + const {accounts, currentAccount} = useSession() + const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() + + React.useEffect(() => { + screen('Choose Account') + }, [screen]) + + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(_(msg`Already signed in as @${account.handle}`)) + } else { + await initSession(account) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(_(msg`Signed in as @${account.handle}`)) + }, 100) + } + } else { + onSelectAccount(account) + } + }, + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], + ) + + return ( + <FormContainer + testID="chooseAccountForm" + title={<Trans>Select account</Trans>}> + <View> + <TextField.Label> + <Trans>Sign in as...</Trans> + </TextField.Label> + <Group> + {accounts.map(account => ( + <AccountItem + key={account.did} + account={account} + onSelect={onSelect} + isCurrentAccount={account.did === currentAccount?.did} + /> + ))} + <Button + testID="chooseNewAccountBtn" + style={[a.flex_1]} + onPress={() => onSelectAccount(undefined)} + label={_(msg`Login to account that is not listed`)}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.flex_row, + a.align_center, + {height: 48}, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <Text + style={[ + a.align_baseline, + a.flex_1, + a.flex_row, + a.py_sm, + {paddingLeft: 48}, + ]}> + <Trans>Other account</Trans> + </Text> + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + </View> + )} + </Button> + </Group> + </View> + <View style={[a.flex_row]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressBack}> + {_(msg`Back`)} + </Button> + <View style={[a.flex_1]} /> + </View> + </FormContainer> + ) +} diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx new file mode 100644 index 000000000..ab9d02536 --- /dev/null +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -0,0 +1,183 @@ +import React, {useState, useEffect} from 'react' +import {ActivityIndicator, Keyboard, View} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {BskyAgent} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as TextField from '#/components/forms/TextField' +import {HostingProvider} from '#/components/forms/HostingProvider' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {atoms as a, useTheme} from '#/alf' +import {useAnalytics} from 'lib/analytics/analytics' +import {isNetworkError} from 'lib/strings/errors' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {FormContainer} from './FormContainer' +import {FormError} from '#/components/forms/FormError' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const ForgotPasswordForm = ({ + error, + serviceUrl, + serviceDescription, + setError, + setServiceUrl, + onPressBack, + onEmailSent, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressBack: () => void + onEmailSent: () => void +}) => { + const t = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [email, setEmail] = useState<string>('') + const {screen} = useAnalytics() + const {_} = useLingui() + + useEffect(() => { + screen('Signin:ForgotPassword') + }, [screen]) + + const onPressSelectService = React.useCallback(() => { + Keyboard.dismiss() + }, []) + + const onPressNext = async () => { + if (!EmailValidator.validate(email)) { + return setError(_(msg`Your email appears to be invalid.`)) + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) + onEmailSent() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <FormContainer + testID="forgotPasswordForm" + title={<Trans>Reset password</Trans>}> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={serviceUrl} + onSelectServiceUrl={setServiceUrl} + onOpenDialog={onPressSelectService} + /> + </View> + <View> + <TextField.Label> + <Trans>Email address</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={At} /> + <TextField.Input + testID="forgotPasswordEmail" + label={_(msg`Enter your email address`)} + autoCapitalize="none" + autoFocus + autoCorrect={false} + autoComplete="email" + value={email} + onChangeText={setEmail} + editable={!isProcessing} + accessibilityHint={_(msg`Sets email for password reset`)} + /> + </TextField.Root> + </View> + <View> + <Text style={[t.atoms.text_contrast_high, a.mb_md]}> + <Trans> + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + </Trans> + </Text> + </View> + <FormError error={error} /> + <View style={[a.flex_row, a.align_center]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressBack}> + <ButtonText> + <Trans>Back</Trans> + </ButtonText> + </Button> + <View style={a.flex_1} /> + {!serviceDescription || isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Button + label={_(msg`Next`)} + variant="solid" + color={email ? 'primary' : 'secondary'} + size="small" + onPress={onPressNext} + disabled={!email}> + <ButtonText> + <Trans>Next</Trans> + </ButtonText> + </Button> + )} + {!serviceDescription || isProcessing ? ( + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> + <Trans>Processing...</Trans> + </Text> + ) : undefined} + </View> + <View + style={[ + t.atoms.border_contrast_medium, + a.border_t, + a.pt_2xl, + a.mt_md, + a.flex_row, + a.justify_center, + ]}> + <Button + testID="skipSendEmailButton" + onPress={onEmailSent} + label={_(msg`Go to next`)} + accessibilityHint={_(msg`Navigates to the next screen`)} + size="small" + variant="ghost" + color="secondary"> + <ButtonText> + <Trans>Already have a code?</Trans> + </ButtonText> + </Button> + </View> + </FormContainer> + ) +} diff --git a/src/screens/Login/FormContainer.tsx b/src/screens/Login/FormContainer.tsx new file mode 100644 index 000000000..cd17d06d7 --- /dev/null +++ b/src/screens/Login/FormContainer.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { + ScrollView, + StyleSheet, + View, + type StyleProp, + type ViewStyle, +} from 'react-native' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {isWeb} from '#/platform/detection' + +export function FormContainer({ + testID, + title, + children, + style, + contentContainerStyle, +}: { + testID?: string + title?: React.ReactNode + children: React.ReactNode + style?: StyleProp<ViewStyle> + contentContainerStyle?: StyleProp<ViewStyle> +}) { + const {gtMobile} = useBreakpoints() + const t = useTheme() + return ( + <ScrollView + testID={testID} + style={[styles.maxHeight, contentContainerStyle]} + keyboardShouldPersistTaps="handled"> + <View + style={[a.gap_lg, a.flex_1, !gtMobile && [a.px_lg, a.pt_md], style]}> + {title && !gtMobile && ( + <Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}> + {title} + </Text> + )} + {children} + </View> + </ScrollView> + ) +} + +const styles = StyleSheet.create({ + maxHeight: { + // @ts-ignore web only -prf + maxHeight: isWeb ? '100vh' : undefined, + height: !isWeb ? '100%' : undefined, + }, +}) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx new file mode 100644 index 000000000..f43f6da1f --- /dev/null +++ b/src/screens/Login/LoginForm.tsx @@ -0,0 +1,265 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + LayoutAnimation, + TextInput, + View, +} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from 'lib/analytics/analytics' +import {createFullHandle} from 'lib/strings/handles' +import {isNetworkError} from 'lib/strings/errors' +import {useSessionApi} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {HostingProvider} from '#/components/forms/HostingProvider' +import {FormContainer} from './FormContainer' +import {FormError} from '#/components/forms/FormError' +import {Loader} from '#/components/Loader' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const LoginForm = ({ + error, + serviceUrl, + serviceDescription, + initialHandle, + setError, + setServiceUrl, + onPressRetryConnect, + onPressBack, + onPressForgotPassword, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + initialHandle: string + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressRetryConnect: () => void + onPressBack: () => void + onPressForgotPassword: () => void +}) => { + const {track} = useAnalytics() + const t = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [identifier, setIdentifier] = useState<string>(initialHandle) + const [password, setPassword] = useState<string>('') + const passwordInputRef = useRef<TextInput>(null) + const {_} = useLingui() + const {login} = useSessionApi() + + const onPressSelectService = React.useCallback(() => { + Keyboard.dismiss() + track('Signin:PressedSelectService') + }, [track]) + + const onPressNext = async () => { + if (isProcessing) return + Keyboard.dismiss() + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setError('') + setIsProcessing(true) + + try { + // try to guess the handle if the user just gave their own username + let fullIdent = identifier + if ( + !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullIdent.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullIdent = createFullHandle( + identifier, + serviceDescription.availableUserDomains[0], + ) + } + } + + // TODO remove double login + await login( + { + service: serviceUrl, + identifier: fullIdent, + password, + }, + 'LoginForm', + ) + } catch (e: any) { + const errMsg = e.toString() + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + logger.debug('Failed to login due to invalid credentials', { + error: errMsg, + }) + setError(_(msg`Invalid username or password`)) + } else if (isNetworkError(e)) { + logger.warn('Failed to login due to network error', {error: errMsg}) + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + logger.warn('Failed to login', {error: errMsg}) + setError(cleanError(errMsg)) + } + } + } + + const isReady = !!serviceDescription && !!identifier && !!password + return ( + <FormContainer testID="loginForm" title={<Trans>Sign in</Trans>}> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={serviceUrl} + onSelectServiceUrl={setServiceUrl} + onOpenDialog={onPressSelectService} + /> + </View> + <View> + <TextField.Label> + <Trans>Account</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={At} /> + <TextField.Input + testID="loginUsernameInput" + label={_(msg`Username or email address`)} + autoCapitalize="none" + autoFocus + autoCorrect={false} + autoComplete="username" + returnKeyType="next" + textContentType="username" + onSubmitEditing={() => { + passwordInputRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + value={identifier} + onChangeText={str => + setIdentifier((str || '').toLowerCase().trim()) + } + editable={!isProcessing} + accessibilityHint={_( + msg`Input the username or email address you used at signup`, + )} + /> + </TextField.Root> + </View> + <View> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + testID="loginPasswordInput" + inputRef={passwordInputRef} + label={_(msg`Password`)} + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + enablesReturnKeyAutomatically={true} + secureTextEntry={true} + textContentType="password" + clearButtonMode="while-editing" + value={password} + onChangeText={setPassword} + onSubmitEditing={onPressNext} + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing + editable={!isProcessing} + accessibilityHint={ + identifier === '' + ? _(msg`Input your password`) + : _(msg`Input the password tied to ${identifier}`) + } + /> + <Button + testID="forgotPasswordButton" + onPress={onPressForgotPassword} + label={_(msg`Forgot password?`)} + accessibilityHint={_(msg`Opens password reset form`)} + variant="solid" + color="secondary" + style={[ + a.rounded_sm, + t.atoms.bg_contrast_100, + {marginLeft: 'auto', left: 6, padding: 6}, + a.z_10, + ]}> + <ButtonText> + <Trans>Forgot?</Trans> + </ButtonText> + </Button> + </TextField.Root> + </View> + <FormError error={error} /> + <View style={[a.flex_row, a.align_center]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressBack}> + <ButtonText> + <Trans>Back</Trans> + </ButtonText> + </Button> + <View style={a.flex_1} /> + {!serviceDescription && error ? ( + <Button + testID="loginRetryButton" + label={_(msg`Retry`)} + accessibilityHint={_(msg`Retries login`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressRetryConnect}> + {_(msg`Retry`)} + </Button> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : isReady ? ( + <Button + label={_(msg`Next`)} + accessibilityHint={_(msg`Navigates to the next screen`)} + variant="solid" + color="primary" + size="small" + onPress={onPressNext}> + <ButtonText> + <Trans>Next</Trans> + </ButtonText> + {isProcessing && <ButtonIcon icon={Loader} />} + </Button> + ) : undefined} + </View> + </FormContainer> + ) +} diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx new file mode 100644 index 000000000..218cab539 --- /dev/null +++ b/src/screens/Login/PasswordUpdatedForm.tsx @@ -0,0 +1,49 @@ +import React, {useEffect} from 'react' +import {View} from 'react-native' +import {useAnalytics} from 'lib/analytics/analytics' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FormContainer} from './FormContainer' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {atoms as a, useBreakpoints} from '#/alf' + +export const PasswordUpdatedForm = ({ + onPressNext, +}: { + onPressNext: () => void +}) => { + const {screen} = useAnalytics() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + useEffect(() => { + screen('Signin:PasswordUpdatedForm') + }, [screen]) + + return ( + <FormContainer + testID="passwordUpdatedForm" + style={[a.gap_2xl, !gtMobile && a.mt_5xl]}> + <Text style={[a.text_3xl, a.font_bold, a.text_center]}> + <Trans>Password updated!</Trans> + </Text> + <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}> + <Trans>You can now sign in with your new password.</Trans> + </Text> + <View style={[a.flex_row, a.justify_center]}> + <Button + onPress={onPressNext} + label={_(msg`Close alert`)} + accessibilityHint={_(msg`Closes password update alert`)} + variant="solid" + color="primary" + size="medium"> + <ButtonText> + <Trans>Okay</Trans> + </ButtonText> + </Button> + </View> + </FormContainer> + ) +} diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx new file mode 100644 index 000000000..ab0a22367 --- /dev/null +++ b/src/screens/Login/ScreenTransition.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' + +export function ScreenTransition({children}: {children: React.ReactNode}) { + return ( + <Animated.View entering={FadeInRight} exiting={FadeOutLeft}> + {children} + </Animated.View> + ) +} diff --git a/src/screens/Login/ScreenTransition.web.tsx b/src/screens/Login/ScreenTransition.web.tsx new file mode 100644 index 000000000..4583720aa --- /dev/null +++ b/src/screens/Login/ScreenTransition.web.tsx @@ -0,0 +1 @@ +export {Fragment as ScreenTransition} from 'react' diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx new file mode 100644 index 000000000..678440cf4 --- /dev/null +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -0,0 +1,190 @@ +import React, {useState, useEffect} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' + +import {isNetworkError} from 'lib/strings/errors' +import {cleanError} from 'lib/strings/errors' +import {checkAndFormatResetCode} from 'lib/strings/password' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FormContainer} from './FormContainer' +import {Text} from '#/components/Typography' +import * as TextField from '#/components/forms/TextField' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {Button, ButtonText} from '#/components/Button' +import {useTheme, atoms as a} from '#/alf' +import {FormError} from '#/components/forms/FormError' + +export const SetNewPasswordForm = ({ + error, + serviceUrl, + setError, + onPressBack, + onPasswordSet, +}: { + error: string + serviceUrl: string + setError: (v: string) => void + onPressBack: () => void + onPasswordSet: () => void +}) => { + const {screen} = useAnalytics() + const {_} = useLingui() + const t = useTheme() + + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) + + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [password, setPassword] = useState<string>('') + + const onPressNext = async () => { + // Check that the code is correct. We do this again just incase the user enters the code after their pw and we + // don't get to call onBlur first + const formattedCode = checkAndFormatResetCode(resetCode) + // TODO Better password strength check + if (!formattedCode || !password) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.resetPassword({ + token: formattedCode, + password, + }) + onPasswordSet() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setResetCode(formattedCode) + } + + return ( + <FormContainer + testID="setNewPasswordForm" + title={<Trans>Set new password</Trans>}> + <Text> + <Trans> + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + </Trans> + </Text> + + <View> + <TextField.Label>Reset code</TextField.Label> + <TextField.Root> + <TextField.Icon icon={Ticket} /> + <TextField.Input + testID="resetCodeInput" + label={_(msg`Looks like XXXXX-XXXXX`)} + autoCapitalize="none" + autoFocus={true} + autoCorrect={false} + autoComplete="off" + value={resetCode} + onChangeText={setResetCode} + onFocus={() => setError('')} + onBlur={onBlur} + editable={!isProcessing} + accessibilityHint={_( + msg`Input code sent to your email for password reset`, + )} + /> + </TextField.Root> + </View> + + <View> + <TextField.Label>New password</TextField.Label> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + testID="newPasswordInput" + label={_(msg`Enter a password`)} + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + secureTextEntry={true} + textContentType="password" + clearButtonMode="while-editing" + value={password} + onChangeText={setPassword} + onSubmitEditing={onPressNext} + editable={!isProcessing} + accessibilityHint={_(msg`Input new password`)} + /> + </TextField.Root> + </View> + <FormError error={error} /> + <View style={[a.flex_row, a.align_center]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressBack}> + <ButtonText> + <Trans>Back</Trans> + </ButtonText> + </Button> + <View style={a.flex_1} /> + {isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Button + label={_(msg`Next`)} + variant="solid" + color="primary" + size="small" + onPress={onPressNext}> + <ButtonText> + <Trans>Next</Trans> + </ButtonText> + </Button> + )} + {isProcessing ? ( + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> + <Trans>Updating...</Trans> + </Text> + ) : undefined} + </View> + </FormContainer> + ) +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx new file mode 100644 index 000000000..f7a0e29e9 --- /dev/null +++ b/src/screens/Login/index.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import {KeyboardAvoidingView} from 'react-native' +import {useAnalytics} from '#/lib/analytics/analytics' +import {useLingui} from '@lingui/react' + +import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' +import {SessionAccount, useSession} from '#/state/session' +import {DEFAULT_SERVICE} from '#/lib/constants' +import {useLoggedOutView} from '#/state/shell/logged-out' +import {useServiceQuery} from '#/state/queries/service' +import {msg} from '@lingui/macro' +import {logger} from '#/logger' +import {atoms as a} from '#/alf' +import {ChooseAccountForm} from './ChooseAccountForm' +import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm' +import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' +import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm' +import {LoginForm} from '#/screens/Login/LoginForm' +import {ScreenTransition} from './ScreenTransition' + +enum Forms { + Login, + ChooseAccount, + ForgotPassword, + SetNewPassword, + PasswordUpdated, +} + +export const Login = ({onPressBack}: {onPressBack: () => void}) => { + const {_} = useLingui() + + const {accounts} = useSession() + const {track} = useAnalytics() + const {requestedAccountSwitchTo} = useLoggedOutView() + const requestedAccount = accounts.find( + acc => acc.did === requestedAccountSwitchTo, + ) + + const [error, setError] = React.useState<string>('') + const [serviceUrl, setServiceUrl] = React.useState<string>( + requestedAccount?.service || DEFAULT_SERVICE, + ) + const [initialHandle, setInitialHandle] = React.useState<string>( + requestedAccount?.handle || '', + ) + const [currentForm, setCurrentForm] = React.useState<Forms>( + requestedAccount + ? Forms.Login + : accounts.length + ? Forms.ChooseAccount + : Forms.Login, + ) + + const { + data: serviceDescription, + error: serviceError, + refetch: refetchService, + } = useServiceQuery(serviceUrl) + + const onSelectAccount = (account?: SessionAccount) => { + if (account?.service) { + setServiceUrl(account.service) + } + setInitialHandle(account?.handle || '') + setCurrentForm(Forms.Login) + } + + const gotoForm = (form: Forms) => { + setError('') + setCurrentForm(form) + } + + React.useEffect(() => { + if (serviceError) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { + error: String(serviceError), + }) + } else { + setError('') + } + }, [serviceError, serviceUrl, _]) + + const onPressForgotPassword = () => { + track('Signin:PressedForgotPassword') + setCurrentForm(Forms.ForgotPassword) + } + + let content = null + let title = '' + let description = '' + + switch (currentForm) { + case Forms.Login: + title = _(msg`Sign in`) + description = _(msg`Enter your username and password`) + content = ( + <LoginForm + error={error} + serviceUrl={serviceUrl} + serviceDescription={serviceDescription} + initialHandle={initialHandle} + setError={setError} + setServiceUrl={setServiceUrl} + onPressBack={onPressBack} + onPressForgotPassword={onPressForgotPassword} + onPressRetryConnect={refetchService} + /> + ) + break + case Forms.ChooseAccount: + title = _(msg`Sign in`) + description = _(msg`Select from an existing account`) + content = ( + <ChooseAccountForm + onSelectAccount={onSelectAccount} + onPressBack={onPressBack} + /> + ) + break + case Forms.ForgotPassword: + title = _(msg`Forgot Password`) + description = _(msg`Let's get your password reset!`) + content = ( + <ForgotPasswordForm + error={error} + serviceUrl={serviceUrl} + serviceDescription={serviceDescription} + setError={setError} + setServiceUrl={setServiceUrl} + onPressBack={() => gotoForm(Forms.Login)} + onEmailSent={() => gotoForm(Forms.SetNewPassword)} + /> + ) + break + case Forms.SetNewPassword: + title = _(msg`Forgot Password`) + description = _(msg`Let's get your password reset!`) + content = ( + <SetNewPasswordForm + error={error} + serviceUrl={serviceUrl} + setError={setError} + onPressBack={() => gotoForm(Forms.ForgotPassword)} + onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} + /> + ) + break + case Forms.PasswordUpdated: + title = _(msg`Password updated`) + description = _(msg`You can now sign in with your new password.`) + content = ( + <PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} /> + ) + break + } + + return ( + <KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}> + <LoggedOutLayout leadin="" title={title} description={description}> + <ScreenTransition key={currentForm}>{content}</ScreenTransition> + </LoggedOutLayout> + </KeyboardAvoidingView> + ) +} diff --git a/src/screens/Signup/StepCaptcha.tsx b/src/screens/Signup/StepCaptcha.tsx new file mode 100644 index 000000000..c4181e552 --- /dev/null +++ b/src/screens/Signup/StepCaptcha.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' +import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView' +import {createFullHandle} from 'lib/strings/handles' +import {isWeb} from 'platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {FormError} from '#/components/forms/FormError' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' + +const CAPTCHA_PATH = '/gate/signup' + +export function StepCaptcha() { + const {_} = useLingui() + const theme = useTheme() + const {state, dispatch} = useSignupContext() + const submit = useSubmitSignup({state, dispatch}) + + const [completed, setCompleted] = React.useState(false) + + const stateParam = React.useMemo(() => nanoid(15), []) + const url = React.useMemo(() => { + const newUrl = new URL(state.serviceUrl) + newUrl.pathname = CAPTCHA_PATH + newUrl.searchParams.set( + 'handle', + createFullHandle(state.handle, state.userDomain), + ) + newUrl.searchParams.set('state', stateParam) + newUrl.searchParams.set('colorScheme', theme.name) + + return newUrl.href + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + + const onSuccess = React.useCallback( + (code: string) => { + setCompleted(true) + submit(code) + }, + [submit], + ) + + const onError = React.useCallback(() => { + dispatch({ + type: 'setError', + value: _(msg`Error receiving captcha response.`), + }) + }, [_, dispatch]) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View style={[styles.container, completed && styles.center]}> + {!completed ? ( + <CaptchaWebView + url={url} + stateParam={stateParam} + state={state} + onSuccess={onSuccess} + onError={onError} + /> + ) : ( + <ActivityIndicator size="large" /> + )} + </View> + <FormError error={state.error} /> + </View> + </ScreenTransition> + ) +} + +const styles = StyleSheet.create({ + error: { + 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', + }, +}) diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx new file mode 100644 index 000000000..e0a79e8fb --- /dev/null +++ b/src/screens/Signup/StepHandle.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' +import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import * as TextField from '#/components/forms/TextField' +import {useSignupContext} from '#/screens/Signup/state' +import {Text} from '#/components/Typography' +import {atoms as a, useTheme} from '#/alf' +import { + createFullHandle, + IsValidHandle, + validateHandle, +} from 'lib/strings/handles' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' + +export function StepHandle() { + const {_} = useLingui() + const t = useTheme() + const {state, dispatch} = useSignupContext() + + const [validCheck, setValidCheck] = React.useState<IsValidHandle>({ + handleChars: false, + hyphenStartOrEnd: false, + frontLength: false, + totalLength: true, + overall: false, + }) + + useFocusEffect( + React.useCallback(() => { + console.log('run') + setValidCheck(validateHandle(state.handle, state.userDomain)) + }, [state.handle, state.userDomain]), + ) + + const onHandleChange = React.useCallback( + (value: string) => { + if (state.error) { + dispatch({type: 'setError', value: ''}) + } + + dispatch({ + type: 'setHandle', + value, + }) + }, + [dispatch, state.error], + ) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View> + <TextField.Root> + <TextField.Icon icon={At} /> + <TextField.Input + onChangeText={onHandleChange} + label={_(msg`Input your user handle`)} + defaultValue={state.handle} + autoCapitalize="none" + autoCorrect={false} + autoFocus + autoComplete="off" + /> + </TextField.Root> + </View> + <Text style={[a.text_md]}> + <Trans>Your full handle will be</Trans>{' '} + <Text style={[a.text_md, a.font_bold]}> + @{createFullHandle(state.handle, state.userDomain)} + </Text> + </Text> + + <View + style={[ + a.w_full, + a.rounded_sm, + a.border, + a.p_md, + a.gap_sm, + t.atoms.border_contrast_low, + ]}> + {state.error ? ( + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon valid={false} /> + <Text style={[a.text_md, a.flex_1]}>{state.error}</Text> + </View> + ) : undefined} + {validCheck.hyphenStartOrEnd ? ( + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon valid={validCheck.handleChars} /> + <Text style={[a.text_md, a.flex_1]}> + <Trans>Only contains letters, numbers, and hyphens</Trans> + </Text> + </View> + ) : ( + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon valid={validCheck.hyphenStartOrEnd} /> + <Text style={[a.text_md, a.flex_1]}> + <Trans>Doesn't begin or end with a hyphen</Trans> + </Text> + </View> + )} + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> + <IsValidIcon + valid={validCheck.frontLength && validCheck.totalLength} + /> + {!validCheck.totalLength ? ( + <Text style={[a.text_md, a.flex_1]}> + <Trans>No longer than 253 characters</Trans> + </Text> + ) : ( + <Text style={[a.text_md, a.flex_1]}> + <Trans>At least 3 characters</Trans> + </Text> + )} + </View> + </View> + </View> + </ScreenTransition> + ) +} + +function IsValidIcon({valid}: {valid: boolean}) { + const t = useTheme() + if (!valid) { + return <Times size="md" style={{color: t.palette.negative_500}} /> + } + return <Check size="md" style={{color: t.palette.positive_700}} /> +} diff --git a/src/screens/Signup/StepInfo.tsx b/src/screens/Signup/StepInfo.tsx new file mode 100644 index 000000000..30a31884a --- /dev/null +++ b/src/screens/Signup/StepInfo.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {atoms as a} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {is13, is18, useSignupContext} from '#/screens/Signup/state' +import * as DateField from '#/components/forms/DateField' +import {logger} from '#/logger' +import {Loader} from '#/components/Loader' +import {Policies} from 'view/com/auth/create/Policies' +import {HostingProvider} from '#/components/forms/HostingProvider' +import {FormError} from '#/components/forms/FormError' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' + +function sanitizeDate(date: Date): Date { + if (!date || date.toString() === 'Invalid Date') { + logger.error(`Create account: handled invalid date for birthDate`, { + hasDate: !!date, + }) + return new Date() + } + return date +} + +export function StepInfo() { + const {_} = useLingui() + const {state, dispatch} = useSignupContext() + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <FormError error={state.error} /> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={state.serviceUrl} + onSelectServiceUrl={v => + dispatch({type: 'setServiceUrl', value: v}) + } + /> + </View> + {state.isLoading ? ( + <View style={[a.align_center]}> + <Loader size="xl" /> + </View> + ) : state.serviceDescription ? ( + <> + {state.serviceDescription.inviteCodeRequired && ( + <View> + <TextField.Label> + <Trans>Invite code</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Ticket} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setInviteCode', + value: value.trim(), + }) + }} + label={_(msg`Required for this provider`)} + defaultValue={state.inviteCode} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + </TextField.Root> + </View> + )} + <View> + <TextField.Label> + <Trans>Email</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setEmail', + value: value.trim(), + }) + }} + label={_(msg`Enter your email address`)} + defaultValue={state.email} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + </TextField.Root> + </View> + <View> + <TextField.Label> + <Trans>Password</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setPassword', + value, + }) + }} + label={_(msg`Choose your password`)} + defaultValue={state.password} + secureTextEntry + autoComplete="new-password" + /> + </TextField.Root> + </View> + <View> + <DateField.Label> + <Trans>Your birth date</Trans> + </DateField.Label> + <DateField.DateField + testID="date" + value={DateField.utils.toSimpleDateString(state.dateOfBirth)} + onChangeDate={date => { + dispatch({ + type: 'setDateOfBirth', + value: sanitizeDate(new Date(date)), + }) + }} + label={_(msg`Date of birth`)} + accessibilityHint={_(msg`Select your date of birth`)} + /> + </View> + <Policies + serviceDescription={state.serviceDescription} + needsGuardian={!is18(state.dateOfBirth)} + under13={!is13(state.dateOfBirth)} + /> + </> + ) : undefined} + </View> + </ScreenTransition> + ) +} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx new file mode 100644 index 000000000..b1acbbdf0 --- /dev/null +++ b/src/screens/Signup/index.tsx @@ -0,0 +1,225 @@ +import React from 'react' +import {ScrollView, View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' +import { + initialState, + reducer, + SignupContext, + SignupStep, + useSubmitSignup, +} from '#/screens/Signup/state' +import {StepInfo} from '#/screens/Signup/StepInfo' +import {StepHandle} from '#/screens/Signup/StepHandle' +import {StepCaptcha} from '#/screens/Signup/StepCaptcha' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' +import {FEEDBACK_FORM_URL} from 'lib/constants' +import {InlineLink} from '#/components/Link' +import {useServiceQuery} from 'state/queries/service' +import {getAgent} from 'state/session' +import {createFullHandle} from 'lib/strings/handles' +import {useAnalytics} from 'lib/analytics/analytics' + +export function Signup({onPressBack}: {onPressBack: () => void}) { + const {_} = useLingui() + const t = useTheme() + const {screen} = useAnalytics() + const [state, dispatch] = React.useReducer(reducer, initialState) + const submit = useSubmitSignup({state, dispatch}) + + const { + data: serviceInfo, + isFetching, + isError, + refetch, + } = useServiceQuery(state.serviceUrl) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + if (isFetching) { + dispatch({type: 'setIsLoading', value: true}) + } else if (!isFetching) { + dispatch({type: 'setIsLoading', value: false}) + } + }, [isFetching]) + + React.useEffect(() => { + if (isError) { + dispatch({type: 'setServiceDescription', value: undefined}) + dispatch({ + type: 'setError', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } else if (serviceInfo) { + dispatch({type: 'setServiceDescription', value: serviceInfo}) + dispatch({type: 'setError', value: ''}) + } + }, [_, serviceInfo, isError]) + + const onNextPress = React.useCallback(async () => { + if (state.activeStep === SignupStep.HANDLE) { + try { + dispatch({type: 'setIsLoading', value: true}) + + const res = await getAgent().resolveHandle({ + handle: createFullHandle(state.handle, state.userDomain), + }) + + if (res.data.did) { + dispatch({ + type: 'setError', + value: _(msg`That handle is already taken.`), + }) + return + } + } catch (e) { + // Don't have to handle + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + } + + // phoneVerificationRequired is actually whether a captcha is required + if ( + state.activeStep === SignupStep.HANDLE && + !state.serviceDescription?.phoneVerificationRequired + ) { + submit() + return + } + + dispatch({type: 'next'}) + }, [ + _, + state.activeStep, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.userDomain, + submit, + ]) + + const onBackPress = React.useCallback(() => { + if (state.activeStep !== SignupStep.INFO) { + dispatch({type: 'prev'}) + } else { + onPressBack() + } + }, [onPressBack, state.activeStep]) + + return ( + <SignupContext.Provider value={{state, dispatch}}> + <LoggedOutLayout + leadin="" + title={_(msg`Create Account`)} + description={_(msg`We're so excited to have you join us!`)}> + <ScrollView + testID="createAccount" + keyboardShouldPersistTaps="handled" + style={a.h_full} + keyboardDismissMode="on-drag"> + <View + style={[ + a.flex_1, + a.px_xl, + a.gap_3xl, + a.pt_2xl, + {paddingBottom: 100}, + ]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_lg, t.atoms.text_contrast_medium]}> + <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '} + {state.serviceDescription && + !state.serviceDescription.phoneVerificationRequired + ? '2' + : '3'} + </Text> + <Text style={[a.text_3xl, a.font_bold]}> + {state.activeStep === SignupStep.INFO ? ( + <Trans>Your account</Trans> + ) : state.activeStep === SignupStep.HANDLE ? ( + <Trans>Your user handle</Trans> + ) : ( + <Trans>Complete the challenge</Trans> + )} + </Text> + </View> + <View> + {state.activeStep === SignupStep.INFO ? ( + <StepInfo /> + ) : state.activeStep === SignupStep.HANDLE ? ( + <StepHandle /> + ) : ( + <StepCaptcha /> + )} + </View> + + <View style={[a.flex_row, a.justify_between]}> + <Button + label="Back" + variant="solid" + color="secondary" + size="small" + onPress={onBackPress}> + Back + </Button> + {state.activeStep !== SignupStep.CAPTCHA && ( + <> + {isError ? ( + <Button + label="Retry" + variant="solid" + color="primary" + size="small" + disabled={state.isLoading} + onPress={() => refetch()}> + Retry + </Button> + ) : ( + <Button + label="Next" + variant="solid" + color={ + !state.canNext || state.isLoading + ? 'secondary' + : 'primary' + } + size="small" + disabled={!state.canNext || state.isLoading} + onPress={onNextPress}> + <ButtonText>Next</ButtonText> + </Button> + )} + </> + )} + </View> + <View + style={[ + a.w_full, + a.py_lg, + a.px_md, + a.rounded_sm, + t.atoms.bg_contrast_25, + ]}> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + <Trans>Having trouble?</Trans>{' '} + <InlineLink + style={[a.text_md]} + to={FEEDBACK_FORM_URL({email: state.email})}> + <Trans>Contact support</Trans> + </InlineLink> + </Text> + </View> + </View> + </ScrollView> + </LoggedOutLayout> + </SignupContext.Provider> + ) +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts new file mode 100644 index 000000000..1ae43612e --- /dev/null +++ b/src/screens/Signup/state.ts @@ -0,0 +1,320 @@ +import React, {useCallback} from 'react' +import {LayoutAnimation} from 'react-native' +import * as EmailValidator from 'email-validator' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {cleanError} from 'lib/strings/errors' +import { + ComAtprotoServerCreateAccount, + ComAtprotoServerDescribeServer, +} from '@atproto/api' + +import {logger} from '#/logger' +import {DEFAULT_SERVICE, IS_PROD_SERVICE} from 'lib/constants' +import {createFullHandle, validateHandle} from 'lib/strings/handles' +import {getAge} from 'lib/strings/time' +import {useSessionApi} from 'state/session' +import { + DEFAULT_PROD_FEEDS, + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, +} from 'state/queries/preferences' +import {useOnboardingDispatch} from 'state/shell' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export enum SignupStep { + INFO, + HANDLE, + CAPTCHA, +} + +export type SignupState = { + hasPrev: boolean + canNext: boolean + activeStep: SignupStep + + serviceUrl: string + serviceDescription?: ServiceDescription + userDomain: string + dateOfBirth: Date + email: string + password: string + inviteCode: string + handle: string + + error: string + isLoading: boolean +} + +export type SignupAction = + | {type: 'prev'} + | {type: 'next'} + | {type: 'finish'} + | {type: 'setStep'; value: SignupStep} + | {type: 'setServiceUrl'; value: string} + | {type: 'setServiceDescription'; value: ServiceDescription | undefined} + | {type: 'setEmail'; value: string} + | {type: 'setPassword'; value: string} + | {type: 'setDateOfBirth'; value: Date} + | {type: 'setInviteCode'; value: string} + | {type: 'setHandle'; value: string} + | {type: 'setVerificationCode'; value: string} + | {type: 'setError'; value: string} + | {type: 'setCanNext'; value: boolean} + | {type: 'setIsLoading'; value: boolean} + +export const initialState: SignupState = { + hasPrev: false, + canNext: false, + activeStep: SignupStep.INFO, + + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + dateOfBirth: DEFAULT_DATE, + email: '', + password: '', + handle: '', + inviteCode: '', + + error: '', + isLoading: false, +} + +export function is13(date: Date) { + return getAge(date) >= 13 +} + +export function is18(date: Date) { + return getAge(date) >= 18 +} + +export function reducer(s: SignupState, a: SignupAction): SignupState { + let next = {...s} + + switch (a.type) { + case 'prev': { + if (s.activeStep !== SignupStep.INFO) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep-- + next.error = '' + } + break + } + case 'next': { + if (s.activeStep !== SignupStep.CAPTCHA) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep++ + next.error = '' + } + break + } + case 'setStep': { + next.activeStep = a.value + break + } + case 'setServiceUrl': { + next.serviceUrl = a.value + break + } + case 'setServiceDescription': { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + + next.serviceDescription = a.value + next.userDomain = a.value?.availableUserDomains[0] ?? '' + next.isLoading = false + break + } + + case 'setEmail': { + next.email = a.value + break + } + case 'setPassword': { + next.password = a.value + break + } + case 'setDateOfBirth': { + next.dateOfBirth = a.value + break + } + case 'setInviteCode': { + next.inviteCode = a.value + break + } + case 'setHandle': { + next.handle = a.value + break + } + case 'setCanNext': { + next.canNext = a.value + break + } + case 'setIsLoading': { + next.isLoading = a.value + break + } + case 'setError': { + next.error = a.value + break + } + } + + next.hasPrev = next.activeStep !== SignupStep.INFO + + switch (next.activeStep) { + case SignupStep.INFO: { + const isValidEmail = EmailValidator.validate(next.email) + next.canNext = + !!(next.email && next.password && next.dateOfBirth) && + (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) && + is13(next.dateOfBirth) && + isValidEmail + break + } + case SignupStep.HANDLE: { + next.canNext = + !!next.handle && validateHandle(next.handle, next.userDomain).overall + break + } + } + + logger.debug('signup', next) + + if (s.activeStep !== next.activeStep) { + logger.debug('signup: step changed', {activeStep: next.activeStep}) + } + + return next +} + +interface IContext { + state: SignupState + dispatch: React.Dispatch<SignupAction> +} +export const SignupContext = React.createContext<IContext>({} as IContext) +export const useSignupContext = () => React.useContext(SignupContext) + +export function useSubmitSignup({ + state, + dispatch, +}: { + state: SignupState + dispatch: (action: SignupAction) => void +}) { + const {_} = useLingui() + const {createAccount} = useSessionApi() + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() + const onboardingDispatch = useOnboardingDispatch() + + return useCallback( + async (verificationCode?: string) => { + if (!state.email) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(state.email)) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!state.password) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your password.`), + }) + } + if (!state.handle) { + dispatch({type: 'setStep', value: SignupStep.HANDLE}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your handle.`), + }) + } + if ( + state.serviceDescription?.phoneVerificationRequired && + !verificationCode + ) { + dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) + return dispatch({ + type: 'setError', + value: _(msg`Please complete the verification captcha.`), + }) + } + dispatch({type: 'setError', value: ''}) + dispatch({type: 'setIsLoading', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: state.serviceUrl, + email: state.email, + handle: createFullHandle(state.handle, state.userDomain), + password: state.password, + inviteCode: state.inviteCode.trim(), + verificationCode: verificationCode, + }) + setBirthDate({birthDate: state.dateOfBirth}) + if (IS_PROD_SERVICE(state.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + dispatch({ + type: 'setError', + value: _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ), + }) + dispatch({type: 'setStep', value: SignupStep.INFO}) + return + } + + if ([400, 429].includes(e.status)) { + logger.warn('Failed to create account', {message: e}) + } else { + logger.error(`Failed to create account (${e.status} status)`, { + message: e, + }) + } + + const error = cleanError(errMsg) + const isHandleError = error.toLowerCase().includes('handle') + + dispatch({type: 'setIsLoading', value: false}) + dispatch({type: 'setError', value: cleanError(errMsg)}) + dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + }, + [ + state.email, + state.password, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.serviceUrl, + state.userDomain, + state.inviteCode, + state.dateOfBirth, + dispatch, + _, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + ], + ) +} |