[Auth rework] Add a header to login flow (#9368)

This commit is contained in:
Samuel Newman
2026-02-13 20:15:36 +00:00
committed by GitHub
parent 9cdfb40ce7
commit 2e34f965fc
14 changed files with 336 additions and 167 deletions
+2 -2
View File
@@ -43,10 +43,10 @@ export function SwitchAccountDialog({
return ( return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}> <Dialog.ScrollableInner label={_(msg`Switch account`)}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold]}> <Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Switch Account</Trans> <Trans>Switch account</Trans>
</Text> </Text>
<AccountList <AccountList
+23 -18
View File
@@ -1,4 +1,4 @@
import React from 'react' import {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -12,6 +12,7 @@ import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
export const ChooseAccountForm = ({ export const ChooseAccountForm = ({
@@ -21,14 +22,14 @@ export const ChooseAccountForm = ({
onSelectAccount: (account?: SessionAccount) => void onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void onPressBack: () => void
}) => { }) => {
const [pendingDid, setPendingDid] = React.useState<string | null>(null) const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelect = React.useCallback( const onSelect = useCallback(
async (account: SessionAccount) => { async (account: SessionAccount) => {
if (pendingDid) { if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this. // The session API isn't resilient to race conditions so let's just ignore this.
@@ -54,7 +55,7 @@ export const ChooseAccountForm = ({
Toast.show(_(msg`Signed in as @${account.handle}`)) Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) { } catch (e: any) {
logger.error('choose account: initSession failed', { logger.error('choose account: initSession failed', {
message: e.message, message: e instanceof Error ? e.message : 'Unknown error',
}) })
// Move to login form. // Move to login form.
onSelectAccount(account) onSelectAccount(account)
@@ -69,6 +70,7 @@ export const ChooseAccountForm = ({
onSelectAccount, onSelectAccount,
setShowLoggedOut, setShowLoggedOut,
_, _,
ax,
], ],
) )
@@ -78,26 +80,29 @@ export const ChooseAccountForm = ({
titleText={<Trans>Select account</Trans>} titleText={<Trans>Select account</Trans>}
style={web([a.py_2xl])}> style={web([a.py_2xl])}>
<View> <View>
<TextField.LabelText> {IS_WEB && (
<Trans>Sign in as...</Trans> <TextField.LabelText>
</TextField.LabelText> <Trans>Sign in as...</Trans>
</TextField.LabelText>
)}
<AccountList <AccountList
onSelectAccount={onSelect} onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()} onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid} pendingDid={pendingDid}
/> />
</View> </View>
<View style={[a.flex_row]}> {IS_WEB && (
<Button <View style={[a.flex_row]}>
label={_(msg`Back`)} <Button
variant="solid" label={_(msg`Back`)}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText> <ButtonText>{_(msg`Back`)}</ButtonText>
</Button> </Button>
<View style={[a.flex_1]} /> <View style={[a.flex_1]} />
</View> </View>
)}
</FormContainer> </FormContainer>
) )
} }
+36 -28
View File
@@ -1,21 +1,22 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {ActivityIndicator, Keyboard, View} from 'react-native' import {Keyboard, View} from 'react-native'
import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Agent} from '#/state/session/agent' import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider' import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -118,43 +119,50 @@ export const ForgotPasswordForm = ({
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}> <View style={[web([a.flex_row, a.align_center]), a.pt_md]}>
<Button {IS_WEB && (
label={_(msg`Back`)} <>
variant="solid" <Button
color="secondary" label={_(msg`Back`)}
size="large" color="secondary"
onPress={onPressBack}> size="large"
<ButtonText> onPress={onPressBack}>
<Trans>Back</Trans> <ButtonText>
</ButtonText> <Trans>Back</Trans>
</Button> </ButtonText>
<View style={a.flex_1} /> </Button>
{!serviceDescription || isProcessing ? ( <View style={a.flex_1} />
<ActivityIndicator /> </>
)}
{!serviceDescription ? (
<Button
label={_(msg`Connecting to service...`)}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : ( ) : (
<Button <Button
label={_(msg`Next`)} label={_(msg`Next`)}
variant="solid" accessibilityHint={_(msg`Navigates to the next screen`)}
color={'primary'} color="primary"
size="large" size="large"
onPress={onPressNext}> onPress={onPressNext}
disabled={isProcessing}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
</ButtonText> </ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button> </Button>
)} )}
{!serviceDescription || isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Processing...</Trans>
</Text>
) : undefined}
</View> </View>
<View <View
style={[ style={[
t.atoms.border_contrast_medium, t.atoms.border_contrast_medium,
a.border_t, a.border_t,
a.pt_2xl, a.pt_xl,
a.mt_md, a.mt_md,
a.flex_row, a.flex_row,
a.justify_center, a.justify_center,
+5 -6
View File
@@ -1,7 +1,7 @@
import {type StyleProp, View, type ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import type React from 'react' import type React from 'react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useGutters} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function FormContainer({ export function FormContainer({
@@ -16,15 +16,14 @@ export function FormContainer({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const t = useTheme() const gutter = useGutters([0, 'wide'])
return ( return (
<View <View
testID={testID} testID={testID}
style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.py_md], style]}> style={[a.gap_md, a.flex_1, !gtMobile && gutter, style]}>
{titleText && !gtMobile && ( {titleText && !gtMobile && (
<Text style={[a.text_xl, a.font_semi_bold, t.atoms.text_contrast_high]}> <Text style={[a.text_3xl, a.font_bold]}>{titleText}</Text>
{titleText}
</Text>
)} )}
{children} {children}
</View> </View>
+48 -46
View File
@@ -1,11 +1,5 @@
import React, {useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import { import {Keyboard, type TextInput, View} from 'react-native'
ActivityIndicator,
Keyboard,
LayoutAnimation,
type TextInput,
View,
} from 'react-native'
import { import {
ComAtprotoServerCreateSession, ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer, type ComAtprotoServerDescribeServer,
@@ -14,14 +8,13 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, useTheme} from '#/alf' import {atoms as a, ios, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider' import {HostingProvider} from '#/components/forms/HostingProvider'
@@ -31,7 +24,7 @@ import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env' import {IS_IOS, IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -62,9 +55,11 @@ export const LoginForm = ({
onAttemptFailed: () => void onAttemptFailed: () => void
}) => { }) => {
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = const [errorField, setErrorField] = useState<
useState<boolean>(false) 'none' | 'identifier' | 'password' | '2fa'
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '') const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('') const passwordValueRef = useRef<string>('')
const [authFactorToken, setAuthFactorToken] = useState('') const [authFactorToken, setAuthFactorToken] = useState('')
@@ -77,26 +72,28 @@ export const LoginForm = ({
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const onPressSelectService = React.useCallback(() => { const onPressSelectService = useCallback(() => {
Keyboard.dismiss() Keyboard.dismiss()
}, []) }, [])
const onPressNext = async () => { const onPressNext = async () => {
if (isProcessing) return if (isProcessing) return
Keyboard.dismiss() Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('') setError('')
setErrorField('none')
const identifier = identifierValueRef.current.toLowerCase().trim() const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current const password = passwordValueRef.current
if (!identifier) { if (!identifier) {
setError(_(msg`Please enter your username`)) setError(_(msg`Please enter your username`))
setErrorField('identifier')
return return
} }
if (!password) { if (!password) {
setError(_(msg`Please enter your password`)) setError(_(msg`Please enter your password`))
setErrorField('password')
return return
} }
@@ -141,7 +138,6 @@ export const LoginForm = ({
requestNotificationsPermission('Login') requestNotificationsPermission('Login')
} catch (e: any) { } catch (e: any) {
const errMsg = e.toString() const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false) setIsProcessing(false)
if ( if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
@@ -154,6 +150,7 @@ export const LoginForm = ({
error: errMsg, error: errMsg,
}) })
setError(_(msg`Invalid 2FA confirmation code.`)) setError(_(msg`Invalid 2FA confirmation code.`))
setErrorField('2fa')
} else if ( } else if (
errMsg.includes('Authentication Required') || errMsg.includes('Authentication Required') ||
errMsg.includes('Invalid identifier or password') errMsg.includes('Invalid identifier or password')
@@ -178,7 +175,7 @@ export const LoginForm = ({
} }
return ( return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> <FormContainer testID="loginForm" titleText={<Trans>Log in</Trans>}>
<View> <View>
<TextField.LabelText> <TextField.LabelText>
<Trans>Hosting provider</Trans> <Trans>Hosting provider</Trans>
@@ -194,7 +191,7 @@ export const LoginForm = ({
<Trans>Account</Trans> <Trans>Account</Trans>
</TextField.LabelText> </TextField.LabelText>
<View style={[a.gap_sm]}> <View style={[a.gap_sm]}>
<TextField.Root> <TextField.Root isInvalid={errorField === 'identifier'}>
<TextField.Icon icon={At} /> <TextField.Icon icon={At} />
<TextField.Input <TextField.Input
testID="loginUsernameInput" testID="loginUsernameInput"
@@ -209,6 +206,7 @@ export const LoginForm = ({
defaultValue={initialHandle || ''} defaultValue={initialHandle || ''}
onChangeText={v => { onChangeText={v => {
identifierValueRef.current = v identifierValueRef.current = v
if (errorField) setErrorField('none')
}} }}
onSubmitEditing={() => { onSubmitEditing={() => {
passwordRef.current?.focus() passwordRef.current?.focus()
@@ -221,7 +219,7 @@ export const LoginForm = ({
/> />
</TextField.Root> </TextField.Root>
<TextField.Root> <TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={Lock} /> <TextField.Icon icon={Lock} />
<TextField.Input <TextField.Input
testID="loginPasswordInput" testID="loginPasswordInput"
@@ -236,6 +234,7 @@ export const LoginForm = ({
clearButtonMode="while-editing" clearButtonMode="while-editing"
onChangeText={v => { onChangeText={v => {
passwordValueRef.current = v passwordValueRef.current = v
if (errorField) setErrorField('none')
}} }}
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -277,7 +276,7 @@ export const LoginForm = ({
<TextField.LabelText> <TextField.LabelText>
<Trans>2FA Confirmation</Trans> <Trans>2FA Confirmation</Trans>
</TextField.LabelText> </TextField.LabelText>
<TextField.Root> <TextField.Root isInvalid={errorField === '2fa'}>
<TextField.Icon icon={Ticket} /> <TextField.Icon icon={Ticket} />
<TextField.Input <TextField.Input
testID="loginAuthFactorTokenInput" testID="loginAuthFactorTokenInput"
@@ -288,8 +287,11 @@ export const LoginForm = ({
autoComplete="one-time-code" autoComplete="one-time-code"
returnKeyType="done" returnKeyType="done"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
onChangeText={setAuthFactorToken}
value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly
onChangeText={text => {
setAuthFactorToken(text)
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_( accessibilityHint={_(
@@ -308,25 +310,24 @@ export const LoginForm = ({
</View> </View>
)} )}
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}> <View style={[a.pt_md, web([a.justify_between, a.flex_row])]}>
<Button {IS_WEB && (
label={_(msg`Back`)} <Button
variant="solid" label={_(msg`Back`)}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
<ButtonText> <ButtonText>
<Trans>Back</Trans> <Trans>Back</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
<View style={a.flex_1} /> )}
{!serviceDescription && error ? ( {!serviceDescription && error ? (
<Button <Button
testID="loginRetryButton" testID="loginRetryButton"
label={_(msg`Retry`)} label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries signing in`)} accessibilityHint={_(msg`Retries signing in`)}
variant="solid" color="primary_subtle"
color="secondary"
size="large" size="large"
onPress={onPressRetryConnect}> onPress={onPressRetryConnect}>
<ButtonText> <ButtonText>
@@ -334,23 +335,24 @@ export const LoginForm = ({
</ButtonText> </ButtonText>
</Button> </Button>
) : !serviceDescription ? ( ) : !serviceDescription ? (
<> <Button
<ActivityIndicator /> label={_(msg`Connecting to service...`)}
<Text style={[t.atoms.text_contrast_high, a.pl_md]}> size="large"
<Trans>Connecting...</Trans> color="secondary"
</Text> disabled>
</> <ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : ( ) : (
<Button <Button
testID="loginNextButton" testID="loginNextButton"
label={_(msg`Next`)} label={_(msg`Log in`)}
accessibilityHint={_(msg`Navigates to the next screen`)} accessibilityHint={_(msg`Navigates to the next screen`)}
variant="solid"
color="primary" color="primary"
size="large" size="large"
onPress={onPressNext}> onPress={onPressNext}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Log in</Trans>
</ButtonText> </ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />} {isProcessing && <ButtonIcon icon={Loader} />}
</Button> </Button>
+3 -4
View File
@@ -2,7 +2,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
@@ -19,18 +19,17 @@ export const PasswordUpdatedForm = ({
<FormContainer <FormContainer
testID="passwordUpdatedForm" testID="passwordUpdatedForm"
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}> style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
<Text style={[a.text_3xl, a.font_semi_bold, a.text_center]}> <Text style={[a.text_3xl, a.font_bold, a.text_center]}>
<Trans>Password updated!</Trans> <Trans>Password updated!</Trans>
</Text> </Text>
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}> <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
<Trans>You can now sign in with your new password.</Trans> <Trans>You can now sign in with your new password.</Trans>
</Text> </Text>
<View style={[a.flex_row, a.justify_center]}> <View style={web([a.flex_row, a.justify_center])}>
<Button <Button
onPress={onPressNext} onPress={onPressNext}
label={_(msg`Close alert`)} label={_(msg`Close alert`)}
accessibilityHint={_(msg`Closes password update alert`)} accessibilityHint={_(msg`Closes password update alert`)}
variant="solid"
color="primary" color="primary"
size="large"> size="large">
<ButtonText> <ButtonText>
+32 -34
View File
@@ -1,5 +1,5 @@
import {useState} from 'react' import {useState} from 'react'
import {ActivityIndicator, View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -7,14 +7,16 @@ import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password' import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Agent} from '#/state/session/agent' import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {FormError} from '#/components/forms/FormError'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
export const SetNewPasswordForm = ({ export const SetNewPasswordForm = ({
@@ -31,7 +33,6 @@ export const SetNewPasswordForm = ({
onPasswordSet: () => void onPasswordSet: () => void
}) => { }) => {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -163,37 +164,34 @@ export const SetNewPasswordForm = ({
<FormError error={error} /> <FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_lg]}> <View style={[web([a.flex_row, a.align_center]), a.pt_lg]}>
<Button {IS_WEB && (
label={_(msg`Back`)} <>
variant="solid" <Button
color="secondary" label={_(msg`Back`)}
size="large" variant="solid"
onPress={onPressBack}> color="secondary"
<ButtonText> size="large"
<Trans>Back</Trans> onPress={onPressBack}>
</ButtonText> <ButtonText>
</Button> <Trans>Back</Trans>
<View style={a.flex_1} /> </ButtonText>
{isProcessing ? ( </Button>
<ActivityIndicator /> <View style={a.flex_1} />
) : ( </>
<Button
label={_(msg`Next`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
)} )}
{isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}> <Button
<Trans>Updating...</Trans> label={_(msg`Next`)}
</Text> color="primary"
) : undefined} size="large"
onPress={onPressNext}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
</View> </View>
</FormContainer> </FormContainer>
) )
@@ -0,0 +1,92 @@
import {useContext} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {Logomark} from '#/view/icons/Logomark'
import {atoms as a, platform, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
Header,
HEADER_SLOT_SIZE,
} from '#/components/Layout'
import {IS_WEB} from '#/env'
import {AuthLayoutNavigationContext} from '../context'
/**
* This is a simplified version of `Layout.Header` for the auth screens.
*/
export const Slot = Header.Slot
export function Outer({children}: {children: React.ReactNode}) {
const t = useTheme()
const gutters = useGutters([0, 'wide'])
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
]}>
{children}
</View>
)
}
export function Content({children}: {children?: React.ReactNode}) {
return (
<View style={[a.flex_1, a.justify_center, {minHeight: HEADER_SLOT_SIZE}]}>
{IS_WEB ? children : <Logo />}
</View>
)
}
export function Logo() {
const t = useTheme()
return <Logomark fill={t.palette.primary_500} style={[a.mx_auto]} />
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useContext(AuthLayoutNavigationContext)
const onPressBack = (evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
navigation?.goBack()
}
return (
<Slot>
<Button
label={_(msg`Go back`)}
onPress={onPressBack}
size="small"
variant="ghost"
color="secondary"
shape="round"
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
@@ -0,0 +1,23 @@
import {type ButtonProps} from '#/components/Button'
import {Header} from '#/components/Layout'
// TEMP: Web needs a much more comprehensive layout change to support the new design
// so to do this incrementally, we'll do native only and migrate to web later
export const Slot = Header.Slot
export function Outer({}: {children: React.ReactNode}) {
return null
}
export function Content({}: {children?: React.ReactNode}) {
return null
}
export function Logo() {
return null
}
export function BackButton({}: Partial<ButtonProps>) {
return null
}
@@ -0,0 +1,5 @@
import {createContext} from 'react'
export const AuthLayoutNavigationContext = createContext<{
goBack: () => void
} | null>(null)
@@ -0,0 +1 @@
export * as Header from './Header'
+41 -23
View File
@@ -18,6 +18,8 @@ import {atoms as a, native} from '#/alf'
import {ScreenTransition} from '#/components/ScreenTransition' import {ScreenTransition} from '#/components/ScreenTransition'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {ChooseAccountForm} from './ChooseAccountForm' import {ChooseAccountForm} from './ChooseAccountForm'
import * as AuthLayout from './components/AuthLayout'
import {AuthLayoutNavigationContext} from './components/AuthLayout/context'
enum Forms { enum Forms {
Login, Login,
@@ -131,11 +133,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
let content = null let content = null
let title = '' let title = ''
let description = '' let description = ''
let goBack = null
switch (currentForm) { switch (currentForm) {
case Forms.Login: case Forms.Login:
title = _(msg`Sign in`) title = _(msg`Sign in`)
description = _(msg`Enter your username and password`) description = _(msg`Enter your username and password`)
goBack = () =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
content = ( content = (
<LoginForm <LoginForm
error={error} error={error}
@@ -146,9 +151,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
onAttemptFailed={onAttemptFailed} onAttemptFailed={onAttemptFailed}
onAttemptSuccess={onAttemptSuccess} onAttemptSuccess={onAttemptSuccess}
setServiceUrl={setServiceUrl} setServiceUrl={setServiceUrl}
onPressBack={() => onPressBack={goBack}
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
}
onPressForgotPassword={onPressForgotPassword} onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService} onPressRetryConnect={refetchService}
/> />
@@ -157,16 +160,18 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.ChooseAccount: case Forms.ChooseAccount:
title = _(msg`Sign in`) title = _(msg`Sign in`)
description = _(msg`Select from an existing account`) description = _(msg`Select from an existing account`)
goBack = handlePressBack
content = ( content = (
<ChooseAccountForm <ChooseAccountForm
onSelectAccount={onSelectAccount} onSelectAccount={onSelectAccount}
onPressBack={handlePressBack} onPressBack={goBack}
/> />
) )
break break
case Forms.ForgotPassword: case Forms.ForgotPassword:
title = _(msg`Forgot Password`) title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`) description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.Login)
content = ( content = (
<ForgotPasswordForm <ForgotPasswordForm
error={error} error={error}
@@ -174,7 +179,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
serviceDescription={serviceDescription} serviceDescription={serviceDescription}
setError={setError} setError={setError}
setServiceUrl={setServiceUrl} setServiceUrl={setServiceUrl}
onPressBack={() => gotoForm(Forms.Login)} onPressBack={goBack}
onEmailSent={() => gotoForm(Forms.SetNewPassword)} onEmailSent={() => gotoForm(Forms.SetNewPassword)}
/> />
) )
@@ -182,12 +187,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.SetNewPassword: case Forms.SetNewPassword:
title = _(msg`Forgot Password`) title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`) description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.ForgotPassword)
content = ( content = (
<SetNewPasswordForm <SetNewPasswordForm
error={error} error={error}
serviceUrl={serviceUrl} serviceUrl={serviceUrl}
setError={setError} setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)} onPressBack={goBack}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
/> />
) )
@@ -201,23 +207,35 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
break break
} }
const navigation = goBack ? {goBack} : null
return ( return (
<Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}> <AuthLayoutNavigationContext value={navigation}>
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}> <Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
<LoggedOutLayout <KeyboardAvoidingView
leadin="" testID="signIn"
title={title} behavior="padding"
description={description} style={a.flex_1}>
scrollable> <AuthLayout.Header.Outer>
<LayoutAnimationConfig skipEntering> <AuthLayout.Header.BackButton />
<ScreenTransition <AuthLayout.Header.Content />
key={currentForm} <AuthLayout.Header.Slot />
direction={screenTransitionDirection}> </AuthLayout.Header.Outer>
{content} <LoggedOutLayout
</ScreenTransition> leadin=""
</LayoutAnimationConfig> title={title}
</LoggedOutLayout> description={description}
</KeyboardAvoidingView> scrollable>
</Animated.View> <LayoutAnimationConfig skipEntering>
<ScreenTransition
key={currentForm}
direction={screenTransitionDirection}>
{content}
</ScreenTransition>
</LayoutAnimationConfig>
</LoggedOutLayout>
</KeyboardAvoidingView>
</Animated.View>
</AuthLayoutNavigationContext>
) )
} }
+23 -4
View File
@@ -1,10 +1,14 @@
import React from 'react' import {useCallback, useEffect, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {PressableScale} from '#/lib/custom-animations/PressableScale' import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {STALE} from '#/state/queries'
import {profilesQueryKey} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import { import {
useLoggedOutView, useLoggedOutView,
useLoggedOutViewControls, useLoggedOutViewControls,
@@ -35,7 +39,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {requestedAccountSwitchTo} = useLoggedOutView() const {requestedAccountSwitchTo} = useLoggedOutView()
const [screenState, setScreenState] = React.useState<ScreenState>(() => { const [screenState, setScreenState] = useState<ScreenState>(() => {
if (requestedAccountSwitchTo === 'new') { if (requestedAccountSwitchTo === 'new') {
return ScreenState.S_CreateAccount return ScreenState.S_CreateAccount
} else if (requestedAccountSwitchTo === 'starterpack') { } else if (requestedAccountSwitchTo === 'starterpack') {
@@ -48,11 +52,26 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}) })
const {clearRequestedAccount} = useLoggedOutViewControls() const {clearRequestedAccount} = useLoggedOutViewControls()
React.useEffect(() => { const queryClient = useQueryClient()
const {accounts} = useSession()
const agent = useAgent()
useEffect(() => {
const actors = accounts.map(acc => acc.did)
void queryClient.prefetchQuery({
queryKey: profilesQueryKey(actors),
staleTime: STALE.MINUTES.FIVE,
queryFn: async () => {
const res = await agent.getProfiles({actors})
return res.data
},
})
}, [accounts, agent, queryClient])
useEffect(() => {
setMinimalShellMode(true) setMinimalShellMode(true)
}, [setMinimalShellMode]) }, [setMinimalShellMode])
const onPressDismiss = React.useCallback(() => { const onPressDismiss = useCallback(() => {
if (onDismiss) { if (onDismiss) {
onDismiss() onDismiss()
} }
@@ -42,11 +42,11 @@ export const LoggedOutLayout = ({
contentContainerStyle={[ contentContainerStyle={[
{paddingBottom: isKeyboardVisible ? 300 : 0}, {paddingBottom: isKeyboardVisible ? 300 : 0},
]}> ]}>
<View style={a.pt_md}>{children}</View> <View style={a.pt_lg}>{children}</View>
</ScrollView> </ScrollView>
) )
} else { } else {
return <View style={a.pt_md}>{children}</View> return <View style={a.pt_lg}>{children}</View>
} }
} }
return ( return (