diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/Login/ChooseAccountForm.tsx | 189 | ||||
-rw-r--r-- | src/screens/Login/ForgotPasswordForm.tsx | 183 | ||||
-rw-r--r-- | src/screens/Login/FormContainer.tsx | 53 | ||||
-rw-r--r-- | src/screens/Login/FormError.tsx | 34 | ||||
-rw-r--r-- | src/screens/Login/LoginForm.tsx | 258 | ||||
-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 | 168 |
10 files changed, 1135 insertions, 0 deletions
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx new file mode 100644 index 000000000..7a3a4555b --- /dev/null +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -0,0 +1,189 @@ +import React from 'react' +import {TouchableOpacity, 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} + {React.cloneElement(child, { + // @ts-ignore + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + </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 ( + <TouchableOpacity + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[a.flex_1]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in as ${account.handle}`)} + accessibilityHint={_(msg`Double tap to sign in`)}> + <View style={[a.flex_1, a.flex_row, a.align_center, {height: 48}]}> + <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> + </TouchableOpacity> + ) +} +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} + /> + ))} + <TouchableOpacity + testID="chooseNewAccountBtn" + style={[a.flex_1]} + onPress={() => onSelectAccount(undefined)} + accessibilityRole="button" + accessibilityLabel={_(msg`Login to account that is not listed`)} + accessibilityHint=""> + <View + style={[a.flex_row, a.flex_row, a.align_center, {height: 48}]}> + <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> + </TouchableOpacity> + </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..fa674155a --- /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 './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/FormError.tsx b/src/screens/Login/FormError.tsx new file mode 100644 index 000000000..3c6a8649d --- /dev/null +++ b/src/screens/Login/FormError.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' + +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Text} from '#/components/Typography' +import {atoms as a, useTheme} from '#/alf' +import {colors} from '#/lib/styles' + +export function FormError({error}: {error?: string}) { + const t = useTheme() + + if (!error) return null + + return ( + <View style={styles.error}> + <Warning fill={t.palette.white} size="sm" /> + <View style={(a.flex_1, a.ml_sm)}> + <Text style={[{color: t.palette.white}, a.font_bold]}>{error}</Text> + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, +}) diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx new file mode 100644 index 000000000..580155281 --- /dev/null +++ b/src/screens/Login/LoginForm.tsx @@ -0,0 +1,258 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + TextInput, + TouchableOpacity, + 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, 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 './FormError' + +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 () => { + Keyboard.dismiss() + 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, + }) + } catch (e: any) { + const errMsg = e.toString() + 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}`) + } + /> + <TouchableOpacity + testID="forgotPasswordButton" + onPress={onPressForgotPassword} + accessibilityRole="button" + accessibilityLabel={_(msg`Forgot password`)} + accessibilityHint={_(msg`Opens password reset form`)} + style={[ + a.rounded_sm, + t.atoms.bg_contrast_100, + {marginLeft: 'auto', left: 6, padding: 6}, + a.z_10, + ]}> + <ButtonText style={t.atoms.text_contrast_medium}> + <Trans>Forgot?</Trans> + </ButtonText> + </TouchableOpacity> + </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> + </> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : 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> + </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..be0732483 --- /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 './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..da392569a --- /dev/null +++ b/src/screens/Login/index.tsx @@ -0,0 +1,168 @@ +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 onPressRetryConnect = () => refetchService() + 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={onPressRetryConnect} + /> + ) + 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> + ) +} |