move auth screens into the navigation system

This commit is contained in:
Samuel Newman
2025-07-02 14:17:24 +03:00
parent 4ccbae7c30
commit 5f0b20ab6c
16 changed files with 897 additions and 9 deletions
@@ -0,0 +1,22 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'ForgotPassword'>
export function ForgotPasswordScreen({}: Props) {
return (
<Layout.Screen testID="ForgotPasswordScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,109 @@
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useSession} from '#/state/session'
import {
useLoggedOutView,
useLoggedOutViewControls,
} from '#/state/shell/logged-out'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {atoms as a, useTheme} from '#/alf'
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'Landing'>
export function LandingScreen({navigation}: Props) {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {accounts} = useSession()
const {showLoggedOut, requestedAccountSwitchTo} = useLoggedOutView()
const {setShowLoggedOut} = useLoggedOutViewControls()
const requestedAccount = accounts.find(
acc => acc.did === requestedAccountSwitchTo,
)
return (
<Layout.Screen
testID="LandingScreen"
style={{paddingBottom: insets.bottom}}>
{showLoggedOut && (
<Layout.Header.Outer noBottomBorder>
<Layout.Header.Slot />
<Layout.Header.Content />
<Layout.Header.Slot>
<Button
label={_(msg`Close`)}
onPress={() => setShowLoggedOut(false)}
size="small"
color="secondary_inverted"
shape="round"
variant="solid">
<ButtonIcon icon={CloseIcon} />
</Button>
</Layout.Header.Slot>
</Layout.Header.Outer>
)}
<View style={[a.flex_1, a.justify_center, a.align_center]}>
<Logo width={92} fill="sky" />
<View style={[a.pb_sm, a.pt_5xl]}>
<Logotype width={161} fill={t.atoms.text.color} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>What's up?</Trans>
</Text>
</View>
<View
testID="signinOrCreateAccount"
style={[a.px_xl, a.gap_md, a.pb_2xl]}>
<Button
testID="createAccountButton"
onPress={() => navigation.push('SignUpInfo')}
label={_(msg`Create new account`)}
size="large"
variant="solid"
color="primary">
<ButtonText>
<Trans>Create account</Trans>
</ButtonText>
</Button>
<Button
testID="signInButton"
onPress={() => {
if (requestedAccount) {
navigation.push('SignIn', {account: requestedAccount})
} else {
navigation.push(accounts.length > 0 ? 'SelectAccount' : 'SignIn')
}
}}
label={_(msg`Sign in`)}
size="large"
variant="solid"
color="secondary">
<ButtonText>
<Trans>Sign in</Trans>
</ButtonText>
</Button>
</View>
<View
style={[a.px_lg, a.pt_md, a.pb_2xl, a.justify_center, a.align_center]}>
<View>
<AppLanguageDropdown />
</View>
</View>
</Layout.Screen>
)
}
@@ -0,0 +1,22 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'PasswordUpdated'>
export function PasswordUpdatedScreen({}: Props) {
return (
<Layout.Screen testID="PasswordUpdatedScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,78 @@
import {useState} from 'react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, native} from '#/alf'
import {AccountList} from '#/components/AccountList'
import * as TextField from '#/components/forms/TextField'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'SelectAccount'>
export function SelectAccountScreen({navigation}: Props) {
const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui()
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelect = async (account: SessionAccount) => {
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
return
}
if (!account.accessJwt) {
// Move to login form.
navigation.push('SignIn', {account})
return
}
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
return
}
try {
setPendingDid(account.did)
await resumeSession(account)
logger.metric('account:loggedIn', {
logContext: 'ChooseAccountForm',
withPassword: false,
})
Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
})
// Move to login form.
navigation.push('SignIn', {account})
} finally {
setPendingDid(null)
}
}
return (
<Layout.Screen testID="SelectAccountScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content contentContainerStyle={[a.py_xl, native(a.px_xl)]}>
<TextField.LabelText>
<Trans>Sign in as...</Trans>
</TextField.LabelText>
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => navigation.push('SignIn')}
pendingDid={pendingDid}
/>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,22 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'SetNewPassword'>
export function SetNewPasswordScreen({}: Props) {
return (
<Layout.Screen testID="SetNewPasswordScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
+105
View File
@@ -0,0 +1,105 @@
import {useEffect, useRef, useState} from 'react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DEFAULT_SERVICE} from '#/lib/constants'
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
import {atoms as a, useTheme} from '#/alf'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {SignInForm} from './components/SignInForm'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'SignIn'>
export function SignInScreen({
navigation,
route: {params: {account: requestedAccount} = {}},
}: Props) {
const t = useTheme()
const {_} = useLingui()
const failedAttemptCountRef = useRef(0)
const startTimeRef = useRef(Date.now())
const [error, setError] = useState<string>('')
const [serviceUrl, setServiceUrl] = useState<string>(
requestedAccount?.service || DEFAULT_SERVICE,
)
const {
data: serviceDescription,
error: serviceError,
refetch: refetchService,
} = useServiceQuery(serviceUrl)
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),
})
logger.metric('signin:hostingProviderFailedResolution', {})
} else {
setError('')
}
}, [serviceError, serviceUrl, _])
const onPressForgotPassword = () => {
navigation.push('ForgotPassword')
logger.metric('signin:forgotPasswordPressed', {})
}
const onAttemptSuccess = () => {
logger.metric('signin:success', {
isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE,
timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000),
failedAttemptsCount: failedAttemptCountRef.current,
})
}
const onAttemptFailed = () => {
failedAttemptCountRef.current += 1
}
return (
<Layout.Screen testID="SignInScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content contentContainerStyle={[a.px_xl]}>
<Text style={[a.font_heavy, a.text_3xl]}>Log in</Text>
<SignInForm
error={error}
serviceUrl={serviceUrl}
serviceDescription={serviceDescription}
initialHandle={requestedAccount?.handle}
setError={setError}
onAttemptFailed={onAttemptFailed}
onAttemptSuccess={onAttemptSuccess}
setServiceUrl={setServiceUrl}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService}
/>
<Text style={[a.text_md, a.text_center, a.w_full, a.mt_2xl]}>
<Trans>
New to Bluesky?{' '}
<Text
role="link"
onPress={() => navigation.push('SignUpInfo')}
style={[a.text_md, {color: t.palette.primary_500}]}>
Create account
</Text>
</Trans>
</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,22 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'SignUpCaptcha'>
export function SignUpCaptchaScreen({}: Props) {
return (
<Layout.Screen testID="SignUpCaptchaScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,22 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'SignUpHandle'>
export function SignUpHandleScreen({}: Props) {
return (
<Layout.Screen testID="SignUpHandleScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,22 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'SignUpInfo'>
export function SignUpInfoScreen({}: Props) {
return (
<Layout.Screen testID="SignUpInfoScreen">
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,17 @@
import {
type AuthNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AuthNavigatorParams, 'StarterPackLanding'>
export function StarterPackLandingScreen({}: Props) {
return (
<Layout.Screen testID="StarterPackLandingScreen">
<Layout.Content>
<Text>TODO</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,340 @@
import {useRef, useState} from 'react'
import {
ActivityIndicator,
Keyboard,
LayoutAnimation,
type TextInput,
View,
} from 'react-native'
import {
ComAtprotoServerCreateSession,
type 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 * 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'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
export function SignInForm({
error,
serviceUrl,
serviceDescription,
initialHandle = '',
setError,
// setServiceUrl,
onPressRetryConnect,
onPressForgotPassword,
onAttemptSuccess,
onAttemptFailed,
}: {
error: string
serviceUrl: string
serviceDescription: ServiceDescription | undefined
initialHandle?: string
setError: (v: string) => void
setServiceUrl: (v: string) => void
onPressRetryConnect: () => void
onPressForgotPassword: () => void
onAttemptSuccess: () => void
onAttemptFailed: () => void
}) {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const [isAuthFactorTokenValueEmpty, setIsAuthFactorTokenValueEmpty] =
useState(true)
const identifierValueRef = useRef(initialHandle || '')
const passwordValueRef = useRef('')
const authFactorTokenValueRef = useRef('')
const passwordRef = useRef<TextInput>(null)
const {_} = useLingui()
const {login} = useSessionApi()
const requestNotificationsPermission = useRequestNotificationsPermission()
const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
// const onPressSelectService = () => {
// Keyboard.dismiss()
// }
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
setError('')
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
const authFactorToken = authFactorTokenValueRef.current
if (!identifier) {
setError(_(msg`Please enter your username`))
return
}
if (!password) {
setError(_(msg`Please enter your 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',
)
onAttemptSuccess()
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 {
onAttemptFailed()
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') ||
errMsg.includes('Invalid identifier or password')
) {
logger.debug('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(_(msg`Incorrect 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 (
<View style={[a.pt_5xl, a.gap_md]}>
<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`Enter the username or email address you used when you created your account`,
)}
/>
</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`Enter 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="one-time-code"
returnKeyType="done"
textContentType="username"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
onChangeText={v => {
setIsAuthFactorTokenValueEmpty(v === '')
authFactorTokenValueRef.current = v
}}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(
msg`Input the code which has been emailed to you`,
)}
style={[
{
textTransform: isAuthFactorTokenValueEmpty
? 'none'
: 'uppercase',
},
]}
/>
</TextField.Root>
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}>
<Trans>
Check your email for a sign in code and enter it here.
</Trans>
</Text>
</View>
)}
<FormError error={error} />
<View style={[a.pt_md]}>
{!serviceDescription && error ? (
<Button
testID="loginRetryButton"
label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries signing in`)}
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`Log in`)}
accessibilityHint={_(
msg`Attempts to log in using the provided credentials`,
)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Log in</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)}
</View>
{/* <HostingProvider
serviceUrl={serviceUrl}
onSelectServiceUrl={setServiceUrl}
onOpenDialog={onPressSelectService}
minimal
/> */}
</View>
)
}