Files
bsky-social-app/src/screens/Login/LoginForm.tsx
T
2024-09-27 14:01:57 -07:00

331 lines
11 KiB
TypeScript

import React, {useRef, useState} from 'react'
import {
ActivityIndicator,
Keyboard,
LayoutAnimation,
TextInput,
View,
} from 'react-native'
import {
ComAtprotoServerCreateSession,
ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
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 {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
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 t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('')
const authFactorTokenValueRef = useRef<string>('')
const passwordRef = useRef<TextInput>(null)
const {_} = useLingui()
const {login} = useSessionApi()
const requestNotificationsPermission = useRequestNotificationsPermission()
const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const onPressSelectService = React.useCallback(() => {
Keyboard.dismiss()
}, [])
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('')
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
const authFactorToken = authFactorTokenValueRef.current
if (!identifier || !password) {
setError(_(msg`Invalid username or password`))
return
}
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,
authFactorToken: authFactorToken.trim(),
},
'LoginForm',
)
setShowLoggedOut(false)
setHasCheckedForStarterPack(true)
requestNotificationsPermission('Login')
} catch (e: any) {
const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false)
if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
) {
setIsAuthFactorTokenNeeded(true)
} else if (errMsg.includes('Token is invalid')) {
logger.debug('Failed to login due to invalid 2fa token', {
error: errMsg,
})
setError(_(msg`Invalid 2FA confirmation code.`))
} else 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))
}
}
}
return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View>
<TextField.LabelText>
<Trans>Hosting provider</Trans>
</TextField.LabelText>
<HostingProvider
serviceUrl={serviceUrl}
onSelectServiceUrl={setServiceUrl}
onOpenDialog={onPressSelectService}
/>
</View>
<View>
<TextField.LabelText>
<Trans>Account</Trans>
</TextField.LabelText>
<View style={[a.gap_sm]}>
<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"
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
}}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
editable={!isProcessing}
accessibilityHint={_(
msg`Input the username or email address you used at signup`,
)}
/>
</TextField.Root>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="loginPasswordInput"
inputRef={passwordRef}
label={_(msg`Password`)}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
returnKeyType="done"
enablesReturnKeyAutomatically={true}
secureTextEntry={true}
textContentType="password"
clearButtonMode="while-editing"
onChangeText={v => {
passwordValueRef.current = v
}}
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={_(msg`Input your password`)}
/>
<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>
</View>
{isAuthFactorTokenNeeded && (
<View>
<TextField.LabelText>
<Trans>2FA Confirmation</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="loginAuthFactorTokenInput"
label={_(msg`Confirmation code`)}
autoCapitalize="none"
autoFocus
autoCorrect={false}
autoComplete="off"
returnKeyType="done"
textContentType="username"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
onChangeText={v => {
authFactorTokenValueRef.current = v
}}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(
msg`Input the code which has been emailed to you`,
)}
/>
</TextField.Root>
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}>
<Trans>Check your email for a login code and enter it here.</Trans>
</Text>
</View>
)}
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
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="large"
onPress={onPressRetryConnect}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
) : !serviceDescription ? (
<>
<ActivityIndicator />
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : (
<Button
testID="loginNextButton"
label={_(msg`Next`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)}
</View>
</FormContainer>
)
}