import React, {useState, useEffect, useRef} from 'react' import { ActivityIndicator, Keyboard, KeyboardAvoidingView, StyleSheet, TextInput, TouchableOpacity, View, } from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' import {BskyAgent} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {UserAvatar} from '../../util/UserAvatar' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' import {s, colors} from 'lib/styles' import {createFullHandle} from 'lib/strings/handles' import {toNiceDomain} from 'lib/strings/url-helpers' import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' import {ServiceDescription} from 'state/models/session' import {AccountData} from 'state/models/session' import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' enum Forms { Login, ChooseAccount, ForgotPassword, SetNewPassword, PasswordUpdated, } export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() const {track} = useAnalytics() const [error, setError] = useState('') const [retryDescribeTrigger, setRetryDescribeTrigger] = useState({}) const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) const [serviceDescription, setServiceDescription] = useState< ServiceDescription | undefined >(undefined) const [initialHandle, setInitialHandle] = useState('') const [currentForm, setCurrentForm] = useState( store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login, ) const onSelectAccount = (account?: AccountData) => { if (account?.service) { setServiceUrl(account.service) } setInitialHandle(account?.handle || '') setCurrentForm(Forms.Login) } const gotoForm = (form: Forms) => () => { setError('') setCurrentForm(form) } useEffect(() => { let aborted = false setError('') store.session.describeService(serviceUrl).then( desc => { if (aborted) { return } setServiceDescription(desc) }, err => { if (aborted) { return } store.log.warn( `Failed to fetch service description for ${serviceUrl}`, err, ) setError( 'Unable to contact your service. Please check your Internet connection.', ) }, ) return () => { aborted = true } }, [store.session, store.log, serviceUrl, retryDescribeTrigger]) const onPressRetryConnect = () => setRetryDescribeTrigger({}) const onPressForgotPassword = () => { track('Signin:PressedForgotPassword') setCurrentForm(Forms.ForgotPassword) } return ( {currentForm === Forms.Login ? ( ) : undefined} {currentForm === Forms.ChooseAccount ? ( ) : undefined} {currentForm === Forms.ForgotPassword ? ( ) : undefined} {currentForm === Forms.SetNewPassword ? ( ) : undefined} {currentForm === Forms.PasswordUpdated ? ( ) : undefined} ) } const ChooseAccountForm = ({ store, onSelectAccount, onPressBack, }: { store: RootStoreModel onSelectAccount: (account?: AccountData) => void onPressBack: () => void }) => { const {track, screen} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = React.useState(false) React.useEffect(() => { screen('Choose Account') }, [screen]) const onTryAccount = async (account: AccountData) => { if (account.accessJwt && account.refreshJwt) { setIsProcessing(true) if (await store.session.resumeSession(account)) { track('Sign In', {resumedSession: true}) setIsProcessing(false) return } setIsProcessing(false) } onSelectAccount(account) } return ( Sign in as... {store.session.accounts.map(account => ( onTryAccount(account)} accessibilityRole="button" accessibilityLabel={`Sign in as ${account.handle}`} accessibilityHint="Double tap to sign in"> {account.displayName || account.handle}{' '} {account.handle} ))} onSelectAccount(undefined)} accessibilityRole="button" accessibilityLabel="Login to account that is not listed" accessibilityHint=""> Other account Back {isProcessing && } ) } const LoginForm = ({ store, error, serviceUrl, serviceDescription, initialHandle, setError, setServiceUrl, onPressRetryConnect, onPressBack, onPressForgotPassword, }: { store: RootStoreModel 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 pal = usePalette('default') const theme = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [identifier, setIdentifier] = useState(initialHandle) const [password, setPassword] = useState('') const passwordInputRef = useRef(null) const onPressSelectService = () => { store.shell.openModal({ name: 'server-input', initialService: serviceUrl, onSelect: setServiceUrl, }) Keyboard.dismiss() track('Signin:PressedSelectService') } 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], ) } } await store.session.login({ service: serviceUrl, identifier: fullIdent, password, }) } catch (e: any) { const errMsg = e.toString() store.log.warn('Failed to login', e) setIsProcessing(false) if (errMsg.includes('Authentication Required')) { setError('Invalid username or password') } else if (isNetworkError(e)) { setError( 'Unable to contact your service. Please check your Internet connection.', ) } else { setError(cleanError(errMsg)) } } finally { track('Sign In', {resumedSession: false}) } } const isReady = !!serviceDescription && !!identifier && !!password return ( Sign into {toNiceDomain(serviceUrl)} Account { passwordInputRef.current?.focus() }} blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field keyboardAppearance={theme.colorScheme} value={identifier} onChangeText={str => setIdentifier((str || '').toLowerCase().trim()) } editable={!isProcessing} accessibilityLabel="Username or email address" accessibilityHint="Input the username or email address you used at signup" /> Forgot {error ? ( {error} ) : undefined} Back {!serviceDescription && error ? ( Retry ) : !serviceDescription ? ( <> Connecting... ) : isProcessing ? ( ) : isReady ? ( Next ) : undefined} ) } const ForgotPasswordForm = ({ store, error, serviceUrl, serviceDescription, setError, setServiceUrl, onPressBack, onEmailSent, }: { store: RootStoreModel error: string serviceUrl: string serviceDescription: ServiceDescription | undefined setError: (v: string) => void setServiceUrl: (v: string) => void onPressBack: () => void onEmailSent: () => void }) => { const pal = usePalette('default') const theme = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') const {screen} = useAnalytics() useEffect(() => { screen('Signin:ForgotPassword') }, [screen]) const onPressSelectService = () => { store.shell.openModal({ name: 'server-input', initialService: serviceUrl, onSelect: setServiceUrl, }) } const onPressNext = async () => { if (!EmailValidator.validate(email)) { return setError('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() store.log.warn('Failed to request password reset', e) setIsProcessing(false) if (isNetworkError(e)) { setError( 'Unable to contact your service. Please check your Internet connection.', ) } else { setError(cleanError(errMsg)) } } } return ( <> Reset password Enter the email you used to create your account. We'll send you a "reset code" so you can set a new password. {toNiceDomain(serviceUrl)} {error ? ( {error} ) : undefined} Back {!serviceDescription || isProcessing ? ( ) : !email ? ( Next ) : ( Next )} {!serviceDescription || isProcessing ? ( Processing... ) : undefined} ) } const SetNewPasswordForm = ({ store, error, serviceUrl, setError, onPressBack, onPasswordSet, }: { store: RootStoreModel error: string serviceUrl: string setError: (v: string) => void onPressBack: () => void onPasswordSet: () => void }) => { const pal = usePalette('default') const theme = useTheme() const {screen} = useAnalytics() useEffect(() => { screen('Signin:SetNewPasswordForm') }, [screen]) const [isProcessing, setIsProcessing] = useState(false) const [resetCode, setResetCode] = useState('') const [password, setPassword] = useState('') const onPressNext = async () => { setError('') setIsProcessing(true) try { const agent = new BskyAgent({service: serviceUrl}) const token = resetCode.replace(/\s/g, '') await agent.com.atproto.server.resetPassword({ token, password, }) onPasswordSet() } catch (e: any) { const errMsg = e.toString() store.log.warn('Failed to set new password', e) setIsProcessing(false) if (isNetworkError(e)) { setError( 'Unable to contact your service. Please check your Internet connection.', ) } else { setError(cleanError(errMsg)) } } } return ( <> Set new password You will receive an email with a "reset code." Enter that code here, then enter your new password. {error ? ( {error} ) : undefined} Back {isProcessing ? ( ) : !resetCode || !password ? ( Next ) : ( Next )} {isProcessing ? ( Updating... ) : undefined} ) } const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { const {screen} = useAnalytics() useEffect(() => { screen('Signin:PasswordUpdatedForm') }, [screen]) const pal = usePalette('default') return ( <> Password updated! You can now sign in with your new password. Okay ) } const styles = StyleSheet.create({ screenTitle: { marginBottom: 10, marginHorizontal: 20, }, instructions: { marginBottom: 20, marginHorizontal: 20, }, group: { borderWidth: 1, borderRadius: 10, marginBottom: 20, marginHorizontal: 20, }, groupLabel: { paddingHorizontal: 20, paddingBottom: 5, }, groupContent: { borderTopWidth: 1, flexDirection: 'row', alignItems: 'center', }, noTopBorder: { borderTopWidth: 0, }, groupContentIcon: { marginLeft: 10, }, account: { borderTopWidth: 1, paddingHorizontal: 20, paddingVertical: 4, }, accountLast: { borderBottomWidth: 1, marginBottom: 20, paddingVertical: 8, }, textInput: { flex: 1, width: '100%', paddingVertical: 10, paddingHorizontal: 12, fontSize: 17, letterSpacing: 0.25, fontWeight: '400', borderRadius: 10, }, textInputInnerBtn: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 8, marginHorizontal: 6, }, textBtn: { flexDirection: 'row', flex: 1, alignItems: 'center', }, textBtnLabel: { flex: 1, paddingVertical: 10, paddingHorizontal: 12, }, textBtnFakeInnerBtn: { flexDirection: 'row', alignItems: 'center', borderRadius: 6, paddingVertical: 6, paddingHorizontal: 8, marginHorizontal: 6, }, accountText: { flex: 1, flexDirection: 'row', alignItems: 'baseline', paddingVertical: 10, }, accountTextOther: { paddingLeft: 12, }, error: { backgroundColor: colors.red4, flexDirection: 'row', alignItems: 'center', marginTop: -5, marginHorizontal: 20, marginBottom: 15, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 8, }, errorIcon: { borderWidth: 1, borderColor: colors.white, color: colors.white, borderRadius: 30, width: 16, height: 16, alignItems: 'center', justifyContent: 'center', marginRight: 5, }, dimmed: {opacity: 0.5}, })