Automatically resolve hosting provider from handle (#11053)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-07-07 14:21:58 +03:00
committed by GitHub
parent 640662d57c
commit 9ff248528b
23 changed files with 1532 additions and 523 deletions
-62
View File
@@ -1121,69 +1121,7 @@
"count": 2 "count": 2
} }
}, },
"src/screens/Login/ChooseAccountForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/screens/Login/ForgotPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"@typescript-eslint/no-unsafe-call": {
"count": 1
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
}
},
"src/screens/Login/LoginForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 3
},
"@typescript-eslint/no-unsafe-call": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 4
},
"react-hooks/refs": {
"count": 1
}
},
"src/screens/Login/SetNewPasswordForm.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 1
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
}
},
"src/screens/Login/index.tsx": { "src/screens/Login/index.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},
"react-hooks/refs": { "react-hooks/refs": {
"count": 1 "count": 1
}, },
+1
View File
@@ -94,6 +94,7 @@
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.25", "@atproto/api": "0.20.25",
"@atproto/common-web": "0.5.3",
"@atproto/syntax": "0.6.4", "@atproto/syntax": "0.6.4",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
+3
View File
@@ -244,6 +244,9 @@ importers:
'@atproto/api': '@atproto/api':
specifier: 0.20.25 specifier: 0.20.25
version: 0.20.25 version: 0.20.25
'@atproto/common-web':
specifier: 0.5.3
version: 0.5.3
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.4 specifier: 0.6.4
version: 0.6.4 version: 0.6.4
+2 -2
View File
@@ -131,11 +131,11 @@ function DialogInner({
style={web({maxWidth: 500})}> style={web({maxWidth: 500})}>
<View style={[a.relative, a.gap_md, a.w_full]}> <View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Choose your account provider</Trans> <Trans>Choose your hosting provider</Trans>
</Text> </Text>
<SegmentedControl.Root <SegmentedControl.Root
type="tabs" type="tabs"
label={_(msg`Account provider`)} label={_(msg`Hosting provider`)}
value={fixedOption} value={fixedOption}
onChange={setFixedOption}> onChange={setFixedOption}>
<SegmentedControl.Item <SegmentedControl.Item
-30
View File
@@ -1,30 +0,0 @@
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Text} from '#/components/Typography'
export function FormError({error}: {error?: string}) {
const t = useTheme()
if (!error) return null
return (
<View
style={[
{backgroundColor: t.palette.negative_400},
a.flex_row,
a.rounded_sm,
a.p_md,
a.gap_sm,
]}>
<Warning fill={t.palette.white} size="md" />
<View style={[a.flex_1]}>
<Text
style={[{color: t.palette.white}, a.font_semi_bold, a.leading_snug]}>
{error}
</Text>
</View>
</View>
)
}
+4
View File
@@ -47,6 +47,10 @@ const Context = createContext<{
}) })
Context.displayName = 'TextFieldContext' Context.displayName = 'TextFieldContext'
export function useTextFieldContext() {
return useContext(Context)
}
export type RootProps = React.PropsWithChildren< export type RootProps = React.PropsWithChildren<
{isInvalid?: boolean} & TextStyleProp {isInvalid?: boolean} & TextStyleProp
> >
+30
View File
@@ -8,6 +8,7 @@ import {startUriToStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger' import {logger} from '#/logger'
export const BSKY_APP_HOST = 'https://bsky.app' export const BSKY_APP_HOST = 'https://bsky.app'
export const BSKY_HOSTING_ENDSWITH = '.host.bsky.network'
const BSKY_TRUSTED_HOSTS = [ const BSKY_TRUSTED_HOSTS = [
'bsky\\.app', 'bsky\\.app',
'bsky\\.social', 'bsky\\.social',
@@ -91,6 +92,35 @@ export function toBskyAppUrl(url: string): string {
return new URL(url, BSKY_APP_HOST).toString() return new URL(url, BSKY_APP_HOST).toString()
} }
export function toNiceHostingUrl(url: string): string {
try {
const urlp = new URL(url)
if (urlp.host.endsWith(BSKY_HOSTING_ENDSWITH)) {
return 'Bluesky'
}
return urlp.host
} catch {
return url
}
}
/**
* Whether the given service URL points at a Bluesky-operated PDS. True when the
* host is `bsky.social` (the {@link BSKY_SERVICE} host) or ends with
* `.host.bsky.network`. Returns false if the URL can't be parsed.
*/
export function isBlueskyHostedUrl(url: string): boolean {
try {
const {host} = new URL(url)
return (
host === new URL(BSKY_SERVICE).host ||
host.endsWith(BSKY_HOSTING_ENDSWITH)
)
} catch {
return false
}
}
export function isBskyAppUrl(url: string): boolean { export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/') return url.startsWith('https://bsky.app/')
} }
+11 -13
View File
@@ -1,8 +1,6 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session' import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
@@ -24,7 +22,7 @@ export const ChooseAccountForm = ({
onPressBack: () => void onPressBack: () => void
}) => { }) => {
const [pendingDid, setPendingDid] = useState<string | null>(null) const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
@@ -43,7 +41,7 @@ export const ChooseAccountForm = ({
} }
if (account.did === currentAccount?.did) { if (account.did === currentAccount?.did) {
setShowLoggedOut(false) setShowLoggedOut(false)
Toast.show(_(msg`Already signed in as @${account.handle}`)) Toast.show(l`Already signed in as @${account.handle}`)
return return
} }
try { try {
@@ -53,10 +51,10 @@ export const ChooseAccountForm = ({
logContext: 'ChooseAccountForm', logContext: 'ChooseAccountForm',
withPassword: false, withPassword: false,
}) })
Toast.show(_(msg`Signed in as @${account.handle}`)) Toast.show(l`Signed in as @${account.handle}`)
} catch (e: any) { } catch (err) {
logger.warn('choose account: initSession failed', { logger.warn('choose account: initSession failed', {
message: e instanceof Error ? e.message : 'Unknown error', message: err instanceof Error ? err.message : String(err),
}) })
// Move to login form. // Move to login form.
onSelectAccount(account) onSelectAccount(account)
@@ -70,7 +68,7 @@ export const ChooseAccountForm = ({
pendingDid, pendingDid,
onSelectAccount, onSelectAccount,
setShowLoggedOut, setShowLoggedOut,
_, l,
ax, ax,
], ],
) )
@@ -83,11 +81,11 @@ export const ChooseAccountForm = ({
<View> <View>
{IS_WEB && ( {IS_WEB && (
<TextField.LabelText> <TextField.LabelText>
<Trans>Sign in as...</Trans> <Trans>Sign in as</Trans>
</TextField.LabelText> </TextField.LabelText>
)} )}
<AccountList <AccountList
onSelectAccount={onSelect} onSelectAccount={account => void onSelect(account)}
onSelectOther={() => onSelectAccount()} onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid} pendingDid={pendingDid}
/> />
@@ -95,11 +93,11 @@ export const ChooseAccountForm = ({
{IS_WEB && ( {IS_WEB && (
<View style={[a.flex_row]}> <View style={[a.flex_row]}>
<Button <Button
label={_(msg`Back`)} label={l`Back`}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText> <ButtonText>{l`Back`}</ButtonText>
</Button> </Button>
<View style={[a.flex_1]} /> <View style={[a.flex_1]} />
</View> </View>
+22 -28
View File
@@ -1,17 +1,15 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {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} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} 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, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
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'
@@ -42,7 +40,7 @@ export const ForgotPasswordForm = ({
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [email, setEmail] = useState<string>('') const [email, setEmail] = useState<string>('')
const {_} = useLingui() const {t: l} = useLingui()
const onPressSelectService = useCallback(() => { const onPressSelectService = useCallback(() => {
Keyboard.dismiss() Keyboard.dismiss()
@@ -50,7 +48,7 @@ export const ForgotPasswordForm = ({
const onPressNext = async () => { const onPressNext = async () => {
if (!EmailValidator.validate(email)) { if (!EmailValidator.validate(email)) {
return setError(_(msg`Your email appears to be invalid.`)) return setError(l`Your email appears to be invalid.`)
} }
setError('') setError('')
@@ -60,18 +58,15 @@ export const ForgotPasswordForm = ({
const agent = new Agent(null, {service: serviceUrl}) const agent = new Agent(null, {service: serviceUrl})
await agent.com.atproto.server.requestPasswordReset({email}) await agent.com.atproto.server.requestPasswordReset({email})
onEmailSent() onEmailSent()
} catch (e: any) { } catch (err) {
const errMsg = e.toString() logger.warn('Failed to request password reset', {error: err})
logger.warn('Failed to request password reset', {error: e})
setIsProcessing(false) setIsProcessing(false)
if (isNetworkError(e)) { if (isNetworkError(err)) {
setError( setError(
_( l`Unable to contact your service. Please check your Internet connection.`,
msg`Unable to contact your service. Please check your Internet connection.`,
),
) )
} else { } else {
setError(cleanError(errMsg)) setError(cleanError(err))
} }
} }
} }
@@ -98,7 +93,7 @@ export const ForgotPasswordForm = ({
<TextField.Icon icon={At} /> <TextField.Icon icon={At} />
<TextField.Input <TextField.Input
testID="forgotPasswordEmail" testID="forgotPasswordEmail"
label={_(msg`Enter your email address`)} label={l`Enter your email address`}
autoCapitalize="none" autoCapitalize="none"
autoFocus autoFocus
autoCorrect={false} autoCorrect={false}
@@ -106,25 +101,22 @@ export const ForgotPasswordForm = ({
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_(msg`Sets email for password reset`)} accessibilityHint={l`Sets email for password reset`}
/> />
</TextField.Root> </TextField.Root>
</View> </View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}> <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans> <Trans>
Enter the email you used to create your account. We'll send you a Enter the email you used to create your account. We'll send you a
"reset code" so you can set a new password. "reset code" so you can set a new password.
</Trans> </Trans>
</Text> </Text>
{error && <Admonition type="error">{error}</Admonition>}
<FormError error={error} />
<View style={[web([a.flex_row, a.align_center]), a.pt_md]}> <View style={[web([a.flex_row, a.align_center]), a.pt_md]}>
{IS_WEB && ( {IS_WEB && (
<> <>
<Button <Button
label={_(msg`Back`)} label={l`Back`}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
@@ -137,20 +129,22 @@ export const ForgotPasswordForm = ({
)} )}
{!serviceDescription ? ( {!serviceDescription ? (
<Button <Button
label={_(msg`Connecting to service...`)} label={l`Connecting to service...`}
size="large" size="large"
color="secondary" color="secondary"
disabled> disabled>
<ButtonIcon icon={Loader} /> <ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText> <ButtonText>
<Trans>Connecting</Trans>
</ButtonText>
</Button> </Button>
) : ( ) : (
<Button <Button
label={_(msg`Next`)} label={l`Next`}
accessibilityHint={_(msg`Navigates to the next screen`)} accessibilityHint={l`Navigates to the next screen`}
color="primary" color="primary"
size="large" size="large"
onPress={onPressNext} onPress={() => void onPressNext()}
disabled={isProcessing}> disabled={isProcessing}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
@@ -171,8 +165,8 @@ export const ForgotPasswordForm = ({
<Button <Button
testID="skipSendEmailButton" testID="skipSendEmailButton"
onPress={onEmailSent} onPress={onEmailSent}
label={_(msg`Go to next`)} label={l`Go to next`}
accessibilityHint={_(msg`Navigates to the next screen`)} accessibilityHint={l`Navigates to the next screen`}
size="large" size="large"
variant="ghost" variant="ghost"
color="secondary"> color="secondary">
+433 -133
View File
@@ -1,31 +1,42 @@
import {useCallback, useRef, useState} from 'react' import {useRef, useState} from 'react'
import {Keyboard, type TextInput, View} from 'react-native' import {Keyboard, type TextInput, View} from 'react-native'
import { import {
ComAtprotoServerCreateSession, ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer, type ComAtprotoServerDescribeServer,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {isBlueskyHostedUrl, toNiceHostingUrl} from '#/lib/strings/url-helpers'
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 {
type HostingProviderState,
useHostingProvider,
} from '#/state/queries/pds-detection'
import {useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, useTheme, web} from '#/alf' import {atoms as a, native, tokens, useBreakpoints, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError' import {useDialogControl} from '#/components/Dialog'
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 AtIcon} from '#/components/icons/At'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Envelope_Stroke2_Corner0_Rounded as EmailIcon} from '#/components/icons/Envelope'
import {Eye_Stroke2_Corner0_Rounded as EyeIcon} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as TicketIcon} from '#/components/icons/Ticket'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_IOS, IS_WEB} from '#/env' import {IS_IOS, IS_NATIVE} from '#/env'
import {ConfirmHostingProviderDialog} from './components/ConfirmHostingProviderDialog'
import {HostingProviderDialog} from './components/HostingProviderDialog'
import {FormContainer} from './FormContainer' import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -42,6 +53,7 @@ export const LoginForm = ({
onPressForgotPassword, onPressForgotPassword,
onAttemptSuccess, onAttemptSuccess,
onAttemptFailed, onAttemptFailed,
onPressCreateAccount,
}: { }: {
error: string error: string
serviceUrl: string serviceUrl: string
@@ -54,6 +66,7 @@ export const LoginForm = ({
onPressForgotPassword: () => void onPressForgotPassword: () => void
onAttemptSuccess: () => void onAttemptSuccess: () => void
onAttemptFailed: () => void onAttemptFailed: () => void
onPressCreateAccount: () => void
}) => { }) => {
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
@@ -61,51 +74,138 @@ export const LoginForm = ({
'none' | 'identifier' | 'password' | '2fa' 'none' | 'identifier' | 'password' | '2fa'
>('none') >('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '') const [showResolveError, setShowResolveError] = useState(false)
const passwordValueRef = useRef<string>('') const identifierValueRef = useRef(initialHandle || '')
const passwordValueRef = useRef('')
const [identifier, setIdentifier] = useState(initialHandle || '')
const [identifierFocused, setIdentifierFocused] = useState(false)
const [authFactorToken, setAuthFactorToken] = useState('') const [authFactorToken, setAuthFactorToken] = useState('')
const identifierRef = useRef<TextInput>(null) const identifierRef = useRef<TextInput>(null)
const passwordRef = useRef<TextInput>(null) const passwordRef = useRef<TextInput>(null)
const hasFocusedOnce = useRef<boolean>(false) const hasFocusedOnce = useRef(false)
const {_} = useLingui() const [hasPassword, setHasPassword] = useState(false)
const [revealPassword, setRevealPassword] = useState(false)
const {t: l} = useLingui()
const {login} = useSessionApi() const {login} = useSessionApi()
const {accounts} = useSession()
const requestNotificationsPermission = useRequestNotificationsPermission() const requestNotificationsPermission = useRequestNotificationsPermission()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const serverInputControl = useDialogControl()
const confirmHostingProviderControl = useDialogControl()
const [pendingLogin, setPendingLogin] = useState<{
service: string
fullIdent: string
passwordLength: number
} | null>(null)
const hostingProvider = useHostingProvider({
identifier,
defaultService: serviceUrl,
})
const {gtMobile} = useBreakpoints()
const onPressSelectService = useCallback(() => { /*
Keyboard.dismiss() * Surface an inline error on the username field only once detection has
}, []) * settled on an unresolvable identifier and the user has moved on from the
* field. Hidden while focused so we don't nag mid-type, and it clears
* automatically when the identifier resolves or an override is set (both
* move `state.status` away from 'unresolved').
*/
const showUnresolvedError =
hostingProvider.state.status === 'unresolved' && !identifierFocused
/**
* Performs the actual login attempt against a resolved service. Reads the
* password and 2FA token from the current form state, and manages
* `setIsProcessing` itself: it stays processing on success (the app
* transitions away) and clears it on any failure.
*/
const attemptLogin = async (service: string, fullIdent: string) => {
const password = passwordValueRef.current
setIsProcessing(true)
try {
// TODO remove double login
await login(
{
service,
identifier: fullIdent,
password,
authFactorToken: authFactorToken.trim(),
},
'LoginForm',
)
onAttemptSuccess()
setShowLoggedOut(false)
setHasCheckedForStarterPack(true)
void requestNotificationsPermission('Login')
} catch (err) {
const errMsg = String(err)
setIsProcessing(false)
if (
err 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(l`Invalid 2FA confirmation code.`)
setErrorField('2fa')
} else if (
errMsg.includes('Authentication Required') ||
errMsg.includes('Invalid identifier or password')
) {
logger.debug('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(l`Incorrect username or password`)
} else if (isNetworkError(err)) {
logger.warn('Failed to login due to network error', {error: errMsg})
setError(
l`Unable to contact your service. Please check your Internet connection.`,
)
} else {
logger.warn('Failed to login', {error: errMsg})
setError(cleanError(errMsg))
}
}
}
}
const onPressNext = async () => { const onPressNext = async () => {
if (isProcessing) return if (isProcessing) return
Keyboard.dismiss() Keyboard.dismiss()
setError('') setError('')
setErrorField('none') setErrorField('none')
setShowResolveError(false)
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(l`Please enter your username`)
setErrorField('identifier') setErrorField('identifier')
return return
} }
if (!password) { if (!password) {
setError(_(msg`Please enter your password`)) setError(l`Please enter your password`)
setErrorField('password') setErrorField('password')
return return
} }
setIsProcessing(true) setIsProcessing(true)
try {
// try to guess the handle if the user just gave their own username // try to guess the handle if the user just gave their own username
let fullIdent = identifier let fullIdent = identifier
if ( if (
!identifier.includes('@') && // not an email !identifier.includes('@') && // not an email
!identifier.includes('.') && // not a domain !identifier.includes('.') && // not a domain
!identifier.startsWith('did:') && // not a DID
serviceDescription && serviceDescription &&
serviceDescription.availableUserDomains.length > 0 serviceDescription.availableUserDomains.length > 0
) { ) {
@@ -123,83 +223,99 @@ export const LoginForm = ({
} }
} }
// TODO remove double login /*
await login( * Await autodetection against the current identifier before logging in.
{ * If detection is still in flight this waits for it (bypassing the
service: serviceUrl, * debounce); otherwise it resolves near-instantly from cache. Falls back
identifier: fullIdent, * to the default service on anything unresolvable, but a network error
password, * throws - in that case we must NOT log in, since we can't be sure which
authFactorToken: authFactorToken.trim(), * server to send the password to.
}, */
'LoginForm', let service: string
) let did: string | null
onAttemptSuccess() try {
setShowLoggedOut(false) ;({service, did} = await hostingProvider.resolveService(identifier))
setHasCheckedForStarterPack(true) } catch (err) {
requestNotificationsPermission('Login') logger.debug('Failed to resolve hosting provider', {error: String(err)})
} catch (e: any) {
const errMsg = e.toString()
setIsProcessing(false) setIsProcessing(false)
if ( setShowResolveError(true)
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError return
) {
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.`))
setErrorField('2fa')
} 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))
}
} }
/*
* If detection landed on a non-Bluesky server, confirm before sending the
* password, to guard against typosquatted handles capturing credentials. A
* manual override is skipped: choosing a server by hand is explicit user
* consent, so only auto-detected hosts need the guard. An identity the
* user has signed into on this device before is also trusted: a
* typosquatted handle would resolve to the attacker's different DID, so
* DID membership in the account list is the correct skip condition
* (handles and hosts are not stable keys - service URLs drift and pdsUrl
* is often unset).
*/
const isKnownAccount =
did != null && accounts.some(account => account.did === did)
const needsConfirmation =
!isBlueskyHostedUrl(service) &&
hostingProvider.state.status !== 'overridden' &&
!isKnownAccount
if (needsConfirmation) {
setIsProcessing(false)
setPendingLogin({service, fullIdent, passwordLength: password.length})
confirmHostingProviderControl.open()
return
} }
await attemptLogin(service, fullIdent)
} }
return ( return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View> <HostingProviderDialog
<TextField.LabelText> control={serverInputControl}
<Trans>Hosting provider</Trans> currentOverride={
</TextField.LabelText> hostingProvider.state.status === 'overridden'
<HostingProvider ? hostingProvider.state.pdsUrl
serviceUrl={serviceUrl} : null
onSelectServiceUrl={setServiceUrl} }
onOpenDialog={onPressSelectService} isEmail={hostingProvider.state.status === 'email'}
onSelectManual={url => {
hostingProvider.override(url)
setServiceUrl(url)
}}
onSelectAutomatic={() => {
hostingProvider.clearOverride()
setServiceUrl(DEFAULT_SERVICE)
}}
/>
<ConfirmHostingProviderDialog
control={confirmHostingProviderControl}
host={toNiceHostingUrl(pendingLogin?.service ?? '')}
identifier={pendingLogin?.fullIdent ?? ''}
passwordLength={pendingLogin?.passwordLength ?? 0}
onConfirm={() => {
if (pendingLogin) {
void attemptLogin(pendingLogin.service, pendingLogin.fullIdent)
}
}}
/> />
</View>
<View> <View>
<TextField.LabelText> <TextField.LabelText>
<Trans>Account</Trans> <Trans>Username or email</Trans>
</TextField.LabelText> </TextField.LabelText>
<View style={[a.gap_sm]}> <TextField.Root
<TextField.Root isInvalid={errorField === 'identifier'}> isInvalid={errorField === 'identifier' || showUnresolvedError}>
<TextField.Icon icon={At} /> <TextField.Icon
icon={hostingProvider.state.status === 'email' ? EmailIcon : AtIcon}
/>
<TextField.Input <TextField.Input
testID="loginUsernameInput" testID="loginUsernameInput"
inputRef={identifierRef} inputRef={identifierRef}
label={_(msg`Username or email address`)} label={l`Username or email address`}
placeholder={null}
autoCapitalize="none" autoCapitalize="none"
autoFocus={!IS_IOS} autoFocus={!IS_IOS && !initialHandle}
autoCorrect={false} autoCorrect={false}
autoComplete="username" autoComplete="username"
returnKeyType="next" returnKeyType="next"
@@ -207,70 +323,112 @@ export const LoginForm = ({
defaultValue={initialHandle || ''} defaultValue={initialHandle || ''}
onChangeText={v => { onChangeText={v => {
identifierValueRef.current = v identifierValueRef.current = v
setIdentifier(v)
if (errorField) setErrorField('none') if (errorField) setErrorField('none')
if (showResolveError) setShowResolveError(false)
}} }}
onFocus={() => setIdentifierFocused(true)}
onBlur={() => setIdentifierFocused(false)}
onSubmitEditing={() => { onSubmitEditing={() => {
passwordRef.current?.focus() passwordRef.current?.focus()
}} }}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_( accessibilityHint={l`Enter the username or email address you used when you created your account`}
msg`Enter the username or email address you used when you created your account`,
)}
/> />
</TextField.Root> </TextField.Root>
{showUnresolvedError && (
<Text
style={[
a.text_sm,
a.leading_snug,
a.mt_sm,
{color: t.palette.negative_500},
]}>
<Trans>
We couldn't find an account with that username. Please check that
you've typed it correctly, or{' '}
<InlineLinkText
label={l`set your hosting provider manually`}
style={[a.text_sm, a.leading_snug]}
{...createStaticClick(() => serverInputControl.open())}>
set your hosting provider manually
</InlineLinkText>
.
</Trans>
</Text>
)}
</View>
<View>
<TextField.LabelText>
<Trans>Password</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={errorField === 'password'}> <TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={Lock} /> <TextField.Icon icon={LockIcon} />
<TextField.Input <TextField.Input
testID="loginPasswordInput" testID="loginPasswordInput"
inputRef={passwordRef} inputRef={passwordRef}
label={_(msg`Password`)} label={l`Password`}
placeholder={null}
autoCapitalize="none" autoCapitalize="none"
autoFocus={!IS_IOS && !!initialHandle}
autoCorrect={false} autoCorrect={false}
autoComplete="current-password" autoComplete="current-password"
returnKeyType="done" returnKeyType="done"
enablesReturnKeyAutomatically={true} enablesReturnKeyAutomatically={true}
secureTextEntry={true} secureTextEntry={!revealPassword}
clearButtonMode="while-editing"
onChangeText={v => { onChangeText={v => {
passwordValueRef.current = v passwordValueRef.current = v
if (errorField) setErrorField('none') if (errorField) setErrorField('none')
setHasPassword(!!v)
}} }}
onSubmitEditing={onPressNext} onSubmitEditing={() => void 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
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_(msg`Enter your password`)} accessibilityHint={l`Enter your password`}
onLayout={ios(() => { onLayout={
IS_IOS
? () => {
if (hasFocusedOnce.current) return if (hasFocusedOnce.current) return
hasFocusedOnce.current = true hasFocusedOnce.current = true
// kinda dumb, but if we use `autoFocus` to focus // kinda dumb, but if we use `autoFocus` to focus an
// the username input, it happens before the password // input, it happens before the password input gets
// input gets rendered. this breaks the password autofill // rendered. this breaks the password autofill on iOS (it
// on iOS (it only does the username part). delaying // only does the username part). delaying it until both
// it until both inputs are rendered fixes the autofill -sfn // inputs are rendered fixes the autofill. when a handle is
// prefilled we focus the password field directly so the
// user can go straight to typing it -sfn
if (initialHandle) {
passwordRef.current?.focus()
} else {
identifierRef.current?.focus() identifierRef.current?.focus()
})} }
}
: undefined
}
hitSlop={{...HITSLOP_20, right: 0}}
/> />
<RevealPasswordButton
active={revealPassword}
hasPassword={hasPassword}
onPress={() => setRevealPassword(r => !r)}
/>
</TextField.Root>
{!isAuthFactorTokenNeeded && (
<Button <Button
testID="forgotPasswordButton" label={l`Forgot password?`}
onPress={onPressForgotPassword} accessibilityHint={l`Reset your password by sending a code to your email`}
label={_(msg`Forgot password?`)} style={[a.mt_md, a.self_start]}
accessibilityHint={_(msg`Opens password reset form`)} hoverStyle={{opacity: 0.5}}
variant="solid" hitSlop={HITSLOP_10}
color="secondary" onPress={onPressForgotPassword}>
style={[ <ButtonText style={[t.atoms.text_contrast_medium]}>
a.rounded_sm, <Trans>Forgot password?</Trans>
// t.atoms.bg_contrast_100,
{marginLeft: 'auto', left: 6, padding: 6},
a.z_10,
]}>
<ButtonText>
<Trans>Forgot?</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
</TextField.Root> )}
</View>
</View> </View>
{isAuthFactorTokenNeeded && ( {isAuthFactorTokenNeeded && (
<View> <View>
@@ -278,10 +436,10 @@ export const LoginForm = ({
<Trans>2FA Confirmation</Trans> <Trans>2FA Confirmation</Trans>
</TextField.LabelText> </TextField.LabelText>
<TextField.Root isInvalid={errorField === '2fa'}> <TextField.Root isInvalid={errorField === '2fa'}>
<TextField.Icon icon={Ticket} /> <TextField.Icon icon={TicketIcon} />
<TextField.Input <TextField.Input
testID="loginAuthFactorTokenInput" testID="loginAuthFactorTokenInput"
label={_(msg`Confirmation code`)} label={l`Confirmation code`}
autoCapitalize="none" autoCapitalize="none"
autoFocus autoFocus
autoCorrect={false} autoCorrect={false}
@@ -293,11 +451,9 @@ export const LoginForm = ({
setAuthFactorToken(text) setAuthFactorToken(text)
if (errorField) setErrorField('none') if (errorField) setErrorField('none')
}} }}
onSubmitEditing={onPressNext} onSubmitEditing={() => void onPressNext()}
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_( accessibilityHint={l`Input the code which has been emailed to you`}
msg`Input the code which has been emailed to you`,
)}
style={{ style={{
textTransform: authFactorToken === '' ? 'none' : 'uppercase', textTransform: authFactorToken === '' ? 'none' : 'uppercase',
}} }}
@@ -310,11 +466,49 @@ export const LoginForm = ({
</Text> </Text>
</View> </View>
)} )}
<FormError error={error} />
<View style={[a.pt_md, web([a.justify_between, a.flex_row])]}> {/*
{IS_WEB && ( * At most one error is visible at a time. The inline username error
* (under the field) wins; otherwise the resolution-failure error takes
* precedence over the generic form error.
*/}
{!showUnresolvedError &&
(showResolveError ? (
<Admonition.Outer type="error">
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
We couldnt verify your hosting provider. Check your
internet connection, or{' '}
<InlineLinkText
label={l`Set your hosting provider manually`}
style={[a.text_sm, a.leading_snug]}
{...createStaticClick(() => serverInputControl.open())}>
set your hosting provider manually
</InlineLinkText>
.
</Trans>
</Admonition.Text>
</Admonition.Content>
</Admonition.Row>
</Admonition.Outer>
) : (
error && (
<Admonition.Admonition type="error">{error}</Admonition.Admonition>
)
))}
<View
style={[
a.pt_md,
gtMobile && [a.justify_between, a.flex_row, a.gap_sm],
]}>
{gtMobile && (
<>
<Button <Button
label={_(msg`Back`)} label={l`Back`}
color="secondary" color="secondary"
size="large" size="large"
onPress={onPressBack}> onPress={onPressBack}>
@@ -322,12 +516,20 @@ export const LoginForm = ({
<Trans>Back</Trans> <Trans>Back</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
<View style={[a.flex_shrink, a.justify_center, a.ml_auto]}>
<HostingProviderIndicator
state={hostingProvider.state}
onPress={() => serverInputControl.open()}
/>
</View>
</>
)} )}
{!serviceDescription && error ? ( {!serviceDescription && error ? (
<Button <Button
testID="loginRetryButton" testID="loginRetryButton"
label={_(msg`Retry`)} label={l`Retry`}
accessibilityHint={_(msg`Retries signing in`)} accessibilityHint={l`Retries signing in`}
color="primary_subtle" color="primary_subtle"
size="large" size="large"
onPress={onPressRetryConnect}> onPress={onPressRetryConnect}>
@@ -337,21 +539,23 @@ export const LoginForm = ({
</Button> </Button>
) : !serviceDescription ? ( ) : !serviceDescription ? (
<Button <Button
label={_(msg`Connecting to service...`)} label={l`Connecting to service`}
size="large" size="large"
color="secondary" color="secondary"
disabled> disabled>
<ButtonIcon icon={Loader} /> <ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText> <ButtonText>
<Trans>Connecting</Trans>
</ButtonText>
</Button> </Button>
) : ( ) : (
<Button <Button
testID="loginNextButton" testID="loginNextButton"
label={_(msg`Sign in`)} label={l`Sign in`}
accessibilityHint={_(msg`Navigates to the next screen`)} accessibilityHint={l`Navigates to the next screen`}
color="primary" color="primary"
size="large" size="large"
onPress={onPressNext}> onPress={() => void onPressNext()}>
<ButtonText> <ButtonText>
<Trans>Sign in</Trans> <Trans>Sign in</Trans>
</ButtonText> </ButtonText>
@@ -359,6 +563,102 @@ export const LoginForm = ({
</Button> </Button>
)} )}
</View> </View>
{IS_NATIVE && (
<Text style={[a.text_md, native([a.text_center, a.mx_auto]), a.mt_sm]}>
<Trans>
New to Bluesky?{' '}
<InlineLinkText
label={l`Sign up`}
style={[a.text_md, native(a.text_center)]}
{...createStaticClick(() => onPressCreateAccount())}>
Sign up
</InlineLinkText>
</Trans>
</Text>
)}
{!gtMobile && (
<HostingProviderIndicator
state={hostingProvider.state}
onPress={() => serverInputControl.open()}
/>
)}
</FormContainer> </FormContainer>
) )
} }
function RevealPasswordButton({
active,
hasPassword,
onPress,
}: {
active: boolean
hasPassword: boolean
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const context = TextField.useTextFieldContext()
const Icon = !active ? EyeSlashIcon : EyeIcon
if (!hasPassword && !context.focused) return null
return (
<View style={[a.z_10, a.pl_sm, {marginRight: tokens.space.xs * -1}]}>
<Button
testID="showPasswordButton"
onPress={onPress}
label={active ? l`Hide password` : l`Reveal password`}
color="secondary"
size="small"
shape="round"
style={[a.bg_transparent]}
hitSlop={tokens.space.sm}>
<Icon
size="md"
style={[
context.focused ? t.atoms.text : t.atoms.text_contrast_medium,
]}
/>
</Button>
</View>
)
}
function HostingProviderIndicator({
state,
onPress,
}: {
state: HostingProviderState
onPress: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
return (
<Button
label={l`Change hosting provider`}
accessibilityHint={l`Opens a dialog to change the hosting provider you sign in to`}
style={[!gtMobile && [a.mt_auto, a.mb_sm, a.self_center]]}
size="small"
color="secondary"
variant="ghost"
onPress={onPress}>
<ButtonText
style={[t.atoms.text_contrast_medium, a.font_normal]}
numberOfLines={1}>
{state.status === 'detected' || state.status === 'overridden' ? (
<Trans>Hosting provider: {toNiceHostingUrl(state.pdsUrl)}</Trans>
) : state.status === 'email' ? (
<Trans>Hosting provider: Bluesky</Trans>
) : (
<Trans>Hosting provider</Trans>
)}
</ButtonText>
<TinyChevronIcon width={8} style={[t.atoms.text_contrast_medium]} />
</Button>
)
}
+4 -6
View File
@@ -1,7 +1,5 @@
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useBreakpoints, web} from '#/alf' import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
@@ -13,7 +11,7 @@ export const PasswordUpdatedForm = ({
}: { }: {
onPressNext: () => void onPressNext: () => void
}) => { }) => {
const {_} = useLingui() const {t: l} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
return ( return (
@@ -29,8 +27,8 @@ export const PasswordUpdatedForm = ({
<View style={web([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={l`Close alert`}
accessibilityHint={_(msg`Closes password update alert`)} accessibilityHint={l`Closes password update alert`}
color="primary" color="primary"
size="large"> size="large">
<ButtonText> <ButtonText>
+20 -35
View File
@@ -1,16 +1,14 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {cleanError, isNetworkError} from '#/lib/strings/errors' 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, web} from '#/alf' import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
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'
@@ -33,7 +31,7 @@ export const SetNewPasswordForm = ({
onPressBack: () => void onPressBack: () => void
onPasswordSet: () => void onPasswordSet: () => void
}) => { }) => {
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -47,9 +45,7 @@ export const SetNewPasswordForm = ({
if (!formattedCode) { if (!formattedCode) {
setError( setError(
_( l`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
),
) )
ax.metric('signin:passwordResetFailure', {}) ax.metric('signin:passwordResetFailure', {})
return return
@@ -57,7 +53,7 @@ export const SetNewPasswordForm = ({
// TODO Better password strength check // TODO Better password strength check
if (!password) { if (!password) {
setError(_(msg`Please enter a password.`)) setError(l`Please enter a password.`)
return return
} }
@@ -72,19 +68,16 @@ export const SetNewPasswordForm = ({
}) })
onPasswordSet() onPasswordSet()
ax.metric('signin:passwordResetSuccess', {}) ax.metric('signin:passwordResetSuccess', {})
} catch (e: any) { } catch (err) {
const errMsg = e.toString() logger.warn('Failed to set new password', {error: err})
logger.warn('Failed to set new password', {error: e})
ax.metric('signin:passwordResetFailure', {}) ax.metric('signin:passwordResetFailure', {})
setIsProcessing(false) setIsProcessing(false)
if (isNetworkError(e)) { if (isNetworkError(err)) {
setError( setError(
_( l`Unable to contact your service. Please check your Internet connection.`,
msg`Unable to contact your service. Please check your Internet connection.`,
),
) )
} else { } else {
setError(cleanError(errMsg)) setError(cleanError(err))
} }
} }
} }
@@ -93,9 +86,7 @@ export const SetNewPasswordForm = ({
const formattedCode = checkAndFormatResetCode(resetCode) const formattedCode = checkAndFormatResetCode(resetCode)
if (!formattedCode) { if (!formattedCode) {
setError( setError(
_( l`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
),
) )
return return
} }
@@ -112,7 +103,6 @@ export const SetNewPasswordForm = ({
then enter your new password. then enter your new password.
</Trans> </Trans>
</Text> </Text>
<View> <View>
<TextField.LabelText> <TextField.LabelText>
<Trans>Reset code</Trans> <Trans>Reset code</Trans>
@@ -121,7 +111,7 @@ export const SetNewPasswordForm = ({
<TextField.Icon icon={Ticket} /> <TextField.Icon icon={Ticket} />
<TextField.Input <TextField.Input
testID="resetCodeInput" testID="resetCodeInput"
label={_(msg`Looks like XXXXX-XXXXX`)} label={l`Looks like XXXXX-XXXXX`}
autoCapitalize="none" autoCapitalize="none"
autoFocus={true} autoFocus={true}
autoCorrect={false} autoCorrect={false}
@@ -131,13 +121,10 @@ export const SetNewPasswordForm = ({
onFocus={() => setError('')} onFocus={() => setError('')}
onBlur={onBlur} onBlur={onBlur}
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_( accessibilityHint={l`Input code sent to your email for password reset`}
msg`Input code sent to your email for password reset`,
)}
/> />
</TextField.Root> </TextField.Root>
</View> </View>
<View> <View>
<TextField.LabelText> <TextField.LabelText>
<Trans>New password</Trans> <Trans>New password</Trans>
@@ -146,7 +133,7 @@ export const SetNewPasswordForm = ({
<TextField.Icon icon={Lock} /> <TextField.Icon icon={Lock} />
<TextField.Input <TextField.Input
testID="newPasswordInput" testID="newPasswordInput"
label={_(msg`Enter a password`)} label={l`Enter a password`}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
returnKeyType="done" returnKeyType="done"
@@ -156,20 +143,18 @@ export const SetNewPasswordForm = ({
clearButtonMode="while-editing" clearButtonMode="while-editing"
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
onSubmitEditing={onPressNext} onSubmitEditing={() => void onPressNext()}
editable={!isProcessing} editable={!isProcessing}
accessibilityHint={_(msg`Input new password`)} accessibilityHint={l`Input new password`}
/> />
</TextField.Root> </TextField.Root>
</View> </View>
{error && <Admonition type="error">{error}</Admonition>}
<FormError error={error} />
<View style={[web([a.flex_row, a.align_center]), a.pt_lg]}> <View style={[web([a.flex_row, a.align_center]), a.pt_lg]}>
{IS_WEB && ( {IS_WEB && (
<> <>
<Button <Button
label={_(msg`Back`)} label={l`Back`}
variant="solid" variant="solid"
color="secondary" color="secondary"
size="large" size="large"
@@ -183,10 +168,10 @@ export const SetNewPasswordForm = ({
)} )}
<Button <Button
label={_(msg`Next`)} label={l`Next`}
color="primary" color="primary"
size="large" size="large"
onPress={onPressNext} onPress={() => void onPressNext()}
disabled={isProcessing}> disabled={isProcessing}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
@@ -1,7 +1,6 @@
import {useContext} from 'react' import {useContext} from 'react'
import {type GestureResponderEvent, View} from 'react-native' import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {Logomark} from '#/view/icons/Logomark' import {Logomark} from '#/view/icons/Logomark'
@@ -60,7 +59,7 @@ export function Logo() {
} }
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) { export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui() const {t: l} = useLingui()
const navigation = useContext(AuthLayoutNavigationContext) const navigation = useContext(AuthLayoutNavigationContext)
const onPressBack = (evt: GestureResponderEvent) => { const onPressBack = (evt: GestureResponderEvent) => {
@@ -72,7 +71,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
return ( return (
<Slot> <Slot>
<Button <Button
label={_(msg`Go back`)} label={l`Go back`}
onPress={onPressBack} onPress={onPressBack}
size="small" size="small"
variant="ghost" variant="ghost"
@@ -0,0 +1,170 @@
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
import {Person_Stroke2_Corner0_Rounded as PersonIcon} from '#/components/icons/Person'
import {Text} from '#/components/Typography'
/**
* Confirmation gate shown before signing in to an auto-detected third-party
* hosting provider. When a typed handle resolves to a non-Bluesky PDS, this
* dialog exists to prevent typosquatting credential capture: it makes the user
* explicitly acknowledge that their password will be sent to that server before
* we send it, rather than silently trusting whatever host detection returned.
*/
export function ConfirmHostingProviderDialog({
control,
host,
identifier,
passwordLength,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
/** The display host of the resolved PDS, e.g. `example.com`. */
host: string
/** The full handle (or DID) being signed in. */
identifier: string
/**
* The length of the typed password, used to render a masked placeholder in
* the summary card. The password itself is deliberately never passed in.
*/
passwordLength: number
onConfirm: () => void
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
host={host}
identifier={identifier}
passwordLength={passwordLength}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogInner({
host,
identifier,
passwordLength,
onConfirm,
}: {
host: string
identifier: string
passwordLength: number
onConfirm: () => void
}) {
const control = Dialog.useDialogContext()
const {t: l} = useLingui()
const t = useTheme()
return (
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, a.pr_5xl]}>
<Trans>Everything look right?</Trans>
</Text>
<Text nativeID="dialog-description" style={[a.text_md, a.leading_snug]}>
<Trans>
Your username and password will be shared with{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>{host}</Text>
. If you dont recognize this provider, double-check your username.
</Trans>
</Text>
<View
style={[
a.rounded_md,
a.border,
t.atoms.border_contrast_medium,
a.overflow_hidden,
]}>
{[
{
type: 'host',
icon: GlobeIcon,
value: host,
},
{
type: 'identifier',
icon: PersonIcon,
value: identifier,
},
{
type: 'password',
icon: KeyIcon,
value: '•'.repeat(passwordLength),
},
].map((c, i) => (
<Fragment key={c.type}>
{i !== 0 && (
<View style={[a.border_t, t.atoms.border_contrast_medium]} />
)}
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
t.atoms.bg_contrast_25,
{paddingVertical: 10},
]}>
<c.icon
size="sm"
style={[t.atoms.text_contrast_low, native(a.mt_2xs)]}
/>
<Text
numberOfLines={1}
style={[t.atoms.text_contrast_high, a.flex_1, a.text_sm]}>
{c.value}
</Text>
</View>
</Fragment>
))}
</View>
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
This is just a one-time security check, since we haven't seen this
account on this device before.
</Trans>
</Text>
<Button
color="primary"
size="large"
onPress={() => control.close(() => onConfirm())}
label={l`Continue`}
accessibilityHint={l`Continue signing in`}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
</Button>
<Button
color="secondary"
size="large"
onPress={() => control.close()}
label={l`Go back`}
accessibilityHint={l`Cancels signing in so you can check your username`}>
<ButtonText>
<Trans>Go back</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -0,0 +1,262 @@
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import * as persisted from '#/state/persisted'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
type SegmentedControlOptions = 'automatic' | 'manual'
/**
* Login-specific fork of the server-input dialog. Instead of picking between
* "Bluesky" and a custom URL, the user chooses between "Automatic" (the PDS is
* autodetected from the typed identifier) and "Manual" (an explicit PDS URL).
*
* Selecting Automatic clears any manual override; selecting Manual with a
* non-empty address sets it. A Manual selection with an empty address is
* treated as Automatic.
*/
export function HostingProviderDialog({
control,
currentOverride,
isEmail,
onSelectManual,
onSelectAutomatic,
}: {
control: Dialog.DialogOuterProps['control']
/**
* The PDS URL currently forced by a manual override, or `null` when
* detection is automatic. Determines which tab the dialog opens on.
*/
currentOverride: string | null
/**
* Whether the typed identifier is an email address. Emails cannot resolve
* to a PDS, so the Automatic tab explains that detection is unavailable and
* the default service will be used instead.
*/
isEmail: boolean
onSelectManual: (url: string) => void
onSelectAutomatic: () => void
}) {
const ax = useAnalytics()
const formRef = useRef<DialogInnerRef>(null)
// persist these options between dialog open/close
const [fixedOption, setFixedOption] = useState<SegmentedControlOptions>(
currentOverride ? 'manual' : 'automatic',
)
const [previousCustomAddress, setPreviousCustomAddress] = useState(
currentOverride ?? '',
)
const onClose = useCallback(() => {
const result = formRef.current?.getFormState()
const nextOverride = result ?? null
if (nextOverride) {
onSelectManual(nextOverride)
setPreviousCustomAddress(nextOverride)
} else {
onSelectAutomatic()
}
ax.metric('signin:hostingProviderPressed', {
hostingProviderDidChange: nextOverride !== currentOverride,
})
}, [ax, onSelectManual, onSelectAutomatic, currentOverride])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
formRef={formRef}
fixedOption={fixedOption}
setFixedOption={setFixedOption}
initialCustomAddress={previousCustomAddress}
isEmail={isEmail}
/>
</Dialog.Outer>
)
}
type DialogInnerRef = {getFormState: () => string | null}
function DialogInner({
formRef,
fixedOption,
setFixedOption,
initialCustomAddress,
isEmail,
}: {
formRef: React.Ref<DialogInnerRef>
fixedOption: SegmentedControlOptions
setFixedOption: (opt: SegmentedControlOptions) => void
initialCustomAddress: string
isEmail: boolean
}) {
const control = Dialog.useDialogContext()
const {t: l} = useLingui()
const t = useTheme()
const [customAddress, setCustomAddress] = useState(initialCustomAddress)
const [pdsAddressHistory, setPdsAddressHistory] = useState<string[]>(
persisted.get('pdsAddressHistory') || [],
)
useImperativeHandle(
formRef,
() => ({
getFormState: () => {
if (fixedOption !== 'manual') {
return null
}
let url = customAddress.trim().toLowerCase()
if (!url) {
return null
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url === 'localhost' || url.startsWith('localhost:')) {
url = `http://${url}`
} else {
url = `https://${url}`
}
}
if (!pdsAddressHistory.includes(url)) {
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
setPdsAddressHistory(newHistory)
void persisted.write('pdsAddressHistory', newHistory)
}
return url
},
}),
[customAddress, fixedOption, pdsAddressHistory],
)
return (
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, a.pr_5xl]}>
<Trans>Choose your hosting provider</Trans>
</Text>
<SegmentedControl.Root
type="tabs"
label={l`Hosting provider`}
value={fixedOption}
onChange={setFixedOption}>
<SegmentedControl.Item
testID="automaticSelectBtn"
value="automatic"
label={l`Automatic`}>
<SegmentedControl.ItemText>
{l`Automatic`}
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item
testID="manualSelectBtn"
value="manual"
label={l`Manual`}>
<SegmentedControl.ItemText>{l`Manual`}</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
{fixedOption === 'automatic' && (
<View role="tabpanel">
<Admonition type="tip">
{isEmail ? (
<Trans>
Your hosting provider cant be detected from an email address,
so the default Bluesky service will be used. Enter your
username instead, or set your provider manually.
</Trans>
) : (
<Trans>
Your hosting provider is detected automatically from the
username you enter.
</Trans>
)}
</Admonition>
</View>
)}
{fixedOption === 'manual' && (
<View role="tabpanel">
<TextField.LabelText nativeID="address-input-label">
<Trans>Server address</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={Globe} />
<Dialog.Input
testID="customServerTextInput"
value={customAddress}
onChangeText={setCustomAddress}
label="my-server.com"
accessibilityLabelledBy="address-input-label"
autoCapitalize="none"
keyboardType="url"
/>
</TextField.Root>
{pdsAddressHistory.length > 0 && (
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
{pdsAddressHistory.map(uri => (
<Button
key={uri}
variant="ghost"
color="primary"
label={uri}
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
onPress={() => setCustomAddress(uri)}>
<ButtonText>{uri}</ButtonText>
</Button>
))}
</View>
)}
</View>
)}
<View style={[a.py_xs]}>
<Text
nativeID="dialog-description"
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
<Trans>
Bluesky is an open network where you can choose your hosting
provider. If you're a developer, you can host your own server.
</Trans>{' '}
<InlineLinkText
label={l`Learn more about self hosting your PDS.`}
to="https://atproto.com/guides/self-hosting">
<Trans>Learn more.</Trans>
</InlineLinkText>
</Text>
</View>
<Button
testID="doneBtn"
color="primary"
size="large"
onPress={() => control.close()}
label={l`Done`}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+30 -24
View File
@@ -1,8 +1,7 @@
import {useEffect, useRef, useState} from 'react' import {useEffect, useRef, useState} from 'react'
import {KeyboardAvoidingView} from 'react-native' import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {DEFAULT_SERVICE} from '#/lib/constants' import {DEFAULT_SERVICE} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -37,10 +36,16 @@ const OrderedForms = [
Forms.PasswordUpdated, Forms.PasswordUpdated,
] as const ] as const
export const Login = ({onPressBack}: {onPressBack: () => void}) => { export const Login = ({
const {_} = useLingui() onPressBack,
onPressCreateAccount,
}: {
onPressBack: () => void
onPressCreateAccount: () => void
}) => {
const {t: l} = useLingui()
const failedAttemptCountRef = useRef(0) const failedAttemptCountRef = useRef(0)
const startTimeRef = useRef(Date.now()) const [startTime] = useState(() => Date.now())
const {accounts} = useSession() const {accounts} = useSession()
const {requestedAccountSwitchTo} = useLoggedOutView() const {requestedAccountSwitchTo} = useLoggedOutView()
@@ -92,9 +97,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
useEffect(() => { useEffect(() => {
if (serviceError) { if (serviceError) {
setError( setError(
_( l`Unable to contact your service. Please check your Internet connection.`,
msg`Unable to contact your service. Please check your Internet connection.`,
),
) )
logger.warn(`Failed to fetch service description for ${serviceUrl}`, { logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
error: String(serviceError), error: String(serviceError),
@@ -103,7 +106,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
} else { } else {
setError('') setError('')
} }
}, [serviceError, serviceUrl, _]) }, [serviceError, serviceUrl, l, ax])
const onPressForgotPassword = () => { const onPressForgotPassword = () => {
gotoForm(Forms.ForgotPassword) gotoForm(Forms.ForgotPassword)
@@ -121,7 +124,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
const onAttemptSuccess = () => { const onAttemptSuccess = () => {
ax.metric('signin:success', { ax.metric('signin:success', {
isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE, isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE,
timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000), timeTakenSeconds: Math.round((Date.now() - startTime) / 1000),
failedAttemptsCount: failedAttemptCountRef.current, failedAttemptsCount: failedAttemptCountRef.current,
}) })
} }
@@ -137,8 +140,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
switch (currentForm) { switch (currentForm) {
case Forms.Login: case Forms.Login:
title = _(msg`Sign in`) title = l`Sign in`
description = _(msg`Enter your username and password`) description = l`Enter your username and password`
goBack = () => goBack = () =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
content = ( content = (
@@ -153,13 +156,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
setServiceUrl={setServiceUrl} setServiceUrl={setServiceUrl}
onPressBack={goBack} onPressBack={goBack}
onPressForgotPassword={onPressForgotPassword} onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService} onPressRetryConnect={() => void refetchService()}
onPressCreateAccount={onPressCreateAccount}
/> />
) )
break break
case Forms.ChooseAccount: case Forms.ChooseAccount:
title = _(msg`Sign in`) title = l`Sign in`
description = _(msg`Select from an existing account`) description = l`Select from an existing account`
goBack = handlePressBack goBack = handlePressBack
content = ( content = (
<ChooseAccountForm <ChooseAccountForm
@@ -169,8 +173,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
) )
break break
case Forms.ForgotPassword: case Forms.ForgotPassword:
title = _(msg`Forgot Password`) title = l`Forgot Password`
description = _(msg`Let's get your password reset!`) description = l`Let's get your password reset!`
goBack = () => gotoForm(Forms.Login) goBack = () => gotoForm(Forms.Login)
content = ( content = (
<ForgotPasswordForm <ForgotPasswordForm
@@ -185,8 +189,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
) )
break break
case Forms.SetNewPassword: case Forms.SetNewPassword:
title = _(msg`Forgot Password`) title = l`Forgot Password`
description = _(msg`Let's get your password reset!`) description = l`Let's get your password reset!`
goBack = () => gotoForm(Forms.ForgotPassword) goBack = () => gotoForm(Forms.ForgotPassword)
content = ( content = (
<SetNewPasswordForm <SetNewPasswordForm
@@ -199,8 +203,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
) )
break break
case Forms.PasswordUpdated: case Forms.PasswordUpdated:
title = _(msg`Password updated`) title = l`Password updated`
description = _(msg`You can now sign in with your new password.`) description = l`You can now sign in with your new password.`
content = ( content = (
<PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} /> <PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
) )
@@ -215,7 +219,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
<KeyboardAvoidingView <KeyboardAvoidingView
testID="signIn" testID="signIn"
behavior="padding" behavior="padding"
style={a.flex_1}> style={a.flex_1}
automaticOffset>
<AuthLayout.Header.Outer> <AuthLayout.Header.Outer>
<AuthLayout.Header.BackButton /> <AuthLayout.Header.BackButton />
<AuthLayout.Header.Content /> <AuthLayout.Header.Content />
@@ -229,7 +234,8 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
<LayoutAnimationConfig skipEntering> <LayoutAnimationConfig skipEntering>
<ScreenTransition <ScreenTransition
key={currentForm} key={currentForm}
direction={screenTransitionDirection}> direction={screenTransitionDirection}
style={a.flex_1}>
{content} {content}
</ScreenTransition> </ScreenTransition>
</LayoutAnimationConfig> </LayoutAnimationConfig>
+2 -2
View File
@@ -10,7 +10,7 @@ import {logger} from '#/logger'
import {useSignupContext} from '#/screens/Signup/state' import {useSignupContext} from '#/screens/Signup/state'
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {FormError} from '#/components/forms/FormError' import {Admonition} from '#/components/Admonition'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {GCP_PROJECT_ID, IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {GCP_PROJECT_ID, IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {BackNextButtons} from '../BackNextButtons' import {BackNextButtons} from '../BackNextButtons'
@@ -168,7 +168,7 @@ function StepCaptchaInner({
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
)} )}
</View> </View>
<FormError error={state.error} /> {state.error && <Admonition type="error">{state.error}</Admonition>}
</View> </View>
<BackNextButtons <BackNextButtons
hideNext hideNext
+5 -2
View File
@@ -14,7 +14,6 @@ import * as Dialog from '#/components/Dialog'
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import * as DateField from '#/components/forms/DateField' import * as DateField from '#/components/forms/DateField'
import {type DateFieldRef} from '#/components/forms/DateField/types' import {type DateFieldRef} from '#/components/forms/DateField/types'
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 {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
@@ -172,7 +171,11 @@ export function StepInfo({
return ( return (
<> <>
<View style={[a.gap_md, a.pt_lg]}> <View style={[a.gap_md, a.pt_lg]}>
<FormError error={state.error} /> {state.error && (
<Admonition.Admonition type="error">
{state.error}
</Admonition.Admonition>
)}
<HostingProvider <HostingProvider
minimal minimal
serviceUrl={state.serviceUrl} serviceUrl={state.serviceUrl}
+14 -3
View File
@@ -1,6 +1,7 @@
import {useEffect, useReducer, useState} from 'react' import {useEffect, useReducer, useState} from 'react'
import {AppState, type AppStateStatus, View} from 'react-native' import {AppState, type AppStateStatus, View} from 'react-native'
import ReactNativeDeviceAttest from 'react-native-device-attest' import ReactNativeDeviceAttest from 'react-native-device-attest'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {AppBskyGraphStarterpack} from '@atproto/api' import {AppBskyGraphStarterpack} from '@atproto/api'
import {tokens} from '@bsky.app/alf' import {tokens} from '@bsky.app/alf'
@@ -131,6 +132,10 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
return ( return (
<Animated.View exiting={native(FadeIn.duration(90))} style={a.flex_1}> <Animated.View exiting={native(FadeIn.duration(90))} style={a.flex_1}>
<SignupContext.Provider value={{state, dispatch}}> <SignupContext.Provider value={{state, dispatch}}>
<KeyboardAvoidingView
behavior="padding"
style={a.flex_1}
automaticOffset>
<LoggedOutLayout <LoggedOutLayout
leadin="" leadin=""
title={l`Create account`} title={l`Create account`}
@@ -142,10 +147,12 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
starterPack.record, starterPack.record,
AppBskyGraphStarterpack.isRecord, AppBskyGraphStarterpack.isRecord,
) ? ( ) ? (
<Animated.View entering={!isFetchedAtMount ? FadeIn : undefined}> <Animated.View
entering={!isFetchedAtMount ? FadeIn : undefined}>
<LinearGradientBackground <LinearGradientBackground
style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}> style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}>
<Text style={[a.font_semi_bold, a.text_xl, {color: 'white'}]}> <Text
style={[a.font_semi_bold, a.text_xl, {color: 'white'}]}>
{starterPack.record.name} {starterPack.record.name}
</Text> </Text>
<Text style={[{color: 'white'}]}> <Text style={[{color: 'white'}]}>
@@ -177,7 +184,10 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
]}> ]}>
<View style={[a.gap_sm, a.pb_3xl]}> <View style={[a.gap_sm, a.pb_3xl]}>
<Text <Text
style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> style={[
a.font_semi_bold,
t.atoms.text_contrast_medium,
]}>
<Trans> <Trans>
Step {state.activeStep + 1} of{' '} Step {state.activeStep + 1} of{' '}
{state.serviceDescription && {state.serviceDescription &&
@@ -250,6 +260,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
</LayoutAnimationConfig> </LayoutAnimationConfig>
</View> </View>
</LoggedOutLayout> </LoggedOutLayout>
</KeyboardAvoidingView>
</SignupContext.Provider> </SignupContext.Provider>
</Animated.View> </Animated.View>
) )
+330
View File
@@ -0,0 +1,330 @@
import {useState} from 'react'
import {type DidDocument, getPdsEndpoint} from '@atproto/common-web'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {Agent} from '#/state/session/agent'
const RQKEY_ROOT = 'pds-detection'
export const RQKEY = (identifier: string) => [RQKEY_ROOT, identifier]
/**
* Normalize a login identifier for detection: lowercase, trim, and strip a
* single leading `@`. Handles are often typed as `@alice.example.com`; without
* stripping the `@` the identifier looks like an email and detection is
* disabled. A real email (`a@b.com`) has no leading `@`, so it still contains
* an `@` after normalization and classifies as an email correctly.
*/
function normalizeIdentifier(identifier: string): string {
return identifier.trim().toLowerCase().replace(/^@/, '')
}
/**
* Per-request timeout for identity/PDS resolution network calls. Without it a
* hanging plc.directory / did:web `.well-known` fetch (or handle resolution)
* could leave the sign-in button spinning indefinitely.
*/
const RESOLVE_TIMEOUT = 20e3
/**
* Run a resolution network op with a per-request timeout. `run` receives an
* `AbortSignal` that fires after `RESOLVE_TIMEOUT`, so callers that support
* cancellation (fetch, the XRPC client) abort the in-flight request. A timeout
* is surfaced as a network error so the caller fails the login rather than
* silently falling back to the default service.
*/
async function withResolveTimeout<T>(
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT)
try {
return await run(controller.signal)
} catch (err) {
if (controller.signal.aborted) {
throw new Error('Network request failed: resolution timed out')
}
throw err
} finally {
clearTimeout(timer)
}
}
/**
* Whether a non-ok HTTP status from an identity fetch is server-side or
* transient (5xx or 429) rather than a genuine "not found / invalid" (other
* 4xx). Transient statuses must not be treated as "identity doesn't exist",
* since that would silently fall back to the default service.
*/
function isTransientHttpStatus(status: number): boolean {
return status >= 500 || status === 429
}
/**
* Resolve a DID document without a session.
*
* `com.atproto.identity.resolveIdentity` would give us the DID doc in a single
* call, but it requires auth on the entryway and is not implemented on the
* appview, so it is unusable here. Instead we resolve the DID doc directly:
* `did:plc` via the PLC directory, `did:web` via its `.well-known` endpoint.
*
* Returns `null` for a genuine "not found / invalid" response (a 4xx or an
* unsupported DID method). Throws a network error for transient server-side
* failures (5xx, 429) so the caller fails the login rather than silently
* submitting the password to the default service during, e.g., a plc.directory
* blip.
*/
async function resolveDidDoc(
did: string,
signal?: AbortSignal,
): Promise<DidDocument | null> {
if (did.startsWith('did:plc:')) {
const res = await fetch(`https://plc.directory/${did}`, {signal})
if (!res.ok) {
logger.debug('pds-detection: plc.directory returned non-ok status', {
did,
status: res.status,
})
if (isTransientHttpStatus(res.status)) {
throw new Error(
`Network request failed: plc.directory returned ${res.status}`,
)
}
return null
}
return (await res.json()) as DidDocument
}
if (did.startsWith('did:web:')) {
const domain = did.slice('did:web:'.length)
/*
* did:web method-specific ids are domains; a `:` would indicate a path
* component, which the network does not support. Reject those rather than
* building a malformed URL.
*/
if (domain.includes(':')) return null
const res = await fetch(
`https://${decodeURIComponent(domain)}/.well-known/did.json`,
{signal},
)
if (!res.ok) {
logger.debug(
'pds-detection: did:web .well-known returned non-ok status',
{
did,
status: res.status,
},
)
if (isTransientHttpStatus(res.status)) {
throw new Error(
`Network request failed: did:web .well-known returned ${res.status}`,
)
}
return null
}
return (await res.json()) as DidDocument
}
logger.debug('pds-detection: unsupported DID method', {did})
return null
}
/**
* Resolve the identity behind a given identifier (handle or DID).
*
* Returns the resolved DID together with the PDS URL declared by its DID
* document (verbatim). `pdsUrl` is `null` when the DID resolved but its
* document declares no PDS endpoint. Returns `null` altogether when the
* identifier itself cannot be resolved (unknown handle, broken identity,
* unsupported DID method).
*
* Rethrows only on genuine network errors, so a "not found" during typing
* stays quiet.
*/
export async function resolvePdsForIdentifier(
identifier: string,
): Promise<{did: string; pdsUrl: string | null} | null> {
const norm = normalizeIdentifier(identifier)
const agent = new Agent(null, {service: PUBLIC_BSKY_SERVICE})
try {
let did: string
if (norm.startsWith('did:')) {
did = norm
} else {
const res = await withResolveTimeout(signal =>
agent.resolveHandle({handle: norm}, {signal}),
)
did = res.data.did
}
logger.debug('pds-detection: resolved identifier to DID', {
identifier: norm,
did,
})
const doc = await withResolveTimeout(signal => resolveDidDoc(did, signal))
logger.debug('pds-detection: resolved DID doc', {
did,
foundDoc: !!doc,
})
if (!doc) return null
const pds = getPdsEndpoint(doc)
logger.debug('pds-detection: got PDS endpoint', {
did,
pds: pds ?? null,
})
return {did, pdsUrl: pds ?? null}
} catch (err) {
logger.debug('pds-detection: resolution failed', {
identifier: norm,
error: String(err),
isNetworkError: isNetworkError(err),
})
if (isNetworkError(err)) throw err
return null
}
}
/**
* The detection lifecycle for a login identifier, derived from the debounced
* resolution query plus any manual override.
*/
export type HostingProviderState =
/** Empty, or not yet a plausible handle (e.g. a bare username). */
| {status: 'idle'}
/** The identifier is an email address, so PDS detection is skipped. */
| {status: 'email'}
/** A resolution query is in flight for the current identifier. */
| {status: 'detecting'}
/** Resolved to a PDS endpoint. */
| {status: 'detected'; pdsUrl: string}
/**
* The handle genuinely did not resolve (unknown handle, broken identity).
* This is the only state that should admonish the user about typos.
*/
| {status: 'unresolved'}
/**
* Resolution failed for a network/transient reason (offline, plc.directory
* 5xx). Distinct from `unresolved` because it is not evidence the handle is
* invalid, so the UI must not suggest a typo. Pressing "Sign in" surfaces the
* connectivity error via `resolveService` re-throwing.
*/
| {status: 'error'}
/** The user manually selected a provider. */
| {status: 'overridden'; pdsUrl: string}
/**
* Autodetects the hosting provider (PDS) for a login identifier as the user
* types, with a manual override escape hatch.
*
* The effective `service` is `override ?? detected ?? defaultService`.
* `resolveService` awaits any in-flight detection against the current
* (non-debounced) identifier so that pressing "Sign in" mid-detection waits
* for resolution and then continues. It resolves to `{service, did}`: the
* service to log in against, plus the identifier's resolved DID (`null` when
* no DID was resolved - manual override, email, or bare username). It falls
* back to `defaultService` for anything that legitimately can't resolve a PDS
* (emails, bare usernames, unknown handles) but rethrows genuine network
* errors, so a flaky connection fails the login instead of silently
* submitting to the default server.
*/
export function useHostingProvider({
identifier,
defaultService = DEFAULT_SERVICE,
}: {
identifier: string
defaultService?: string
}): {
state: HostingProviderState
service: string
override: (url: string) => void
clearOverride: () => void
resolveService: (
currentIdentifier: string,
) => Promise<{service: string; did: string | null}>
} {
const queryClient = useQueryClient()
const [override, setOverride] = useState<string | null>(null)
const normalized = normalizeIdentifier(identifier)
const isEmail = normalized.includes('@')
const isPlausibleHandle =
!!normalized &&
!isEmail &&
(normalized.includes('.') || normalized.startsWith('did:'))
const debounced = useDebouncedValue(normalized, 500)
const enabled = isPlausibleHandle && normalized === debounced
const query = useQuery({
enabled,
queryKey: RQKEY(debounced),
queryFn: () => resolvePdsForIdentifier(debounced),
staleTime: STALE.MINUTES.FIVE,
})
let state: HostingProviderState
if (override != null) {
state = {status: 'overridden', pdsUrl: override}
} else if (isEmail) {
state = {status: 'email'}
} else if (!isPlausibleHandle) {
state = {status: 'idle'}
} else if (normalized !== debounced) {
/*
* The identifier changed but the debounce hasn't caught up, so the query is
* still keyed on the old value. Report 'detecting' rather than the stale
* query state, which would otherwise show the previous handle's PDS as
* 'detected'.
*/
state = {status: 'detecting'}
} else if (query.isPending || query.isFetching) {
state = {status: 'detecting'}
} else if (query.isError && isNetworkError(query.error)) {
/*
* A network/transient failure is not evidence the handle is invalid, so
* report 'error' instead of 'unresolved' to avoid a misleading typo hint.
*/
state = {status: 'error'}
} else if (query.isError || query.data == null || query.data.pdsUrl == null) {
state = {status: 'unresolved'}
} else {
state = {status: 'detected', pdsUrl: query.data.pdsUrl}
}
const service =
override ?? (state.status === 'detected' ? state.pdsUrl : defaultService)
return {
state,
service,
override: (url: string) => setOverride(url),
clearOverride: () => setOverride(null),
resolveService: async (currentIdentifier: string) => {
if (override != null) return {service: override, did: null}
const norm = normalizeIdentifier(currentIdentifier)
// Emails and bare usernames can't resolve a PDS on their own.
if (norm.includes('@')) return {service: defaultService, did: null}
if (!norm.includes('.') && !norm.startsWith('did:')) {
return {service: defaultService, did: null}
}
/*
* `resolvePdsForIdentifier` only throws on genuine network errors;
* anything unresolvable (unknown handle, broken identity) resolves to
* `null`, which we treat as the default service. Network errors are left
* to propagate so the caller can fail the login rather than silently
* submit the password to the wrong server.
*/
const resolved = await queryClient.ensureQueryData({
queryKey: RQKEY(norm),
queryFn: () => resolvePdsForIdentifier(norm),
staleTime: STALE.MINUTES.FIVE,
})
// The DID is known even when its doc declares no PDS endpoint.
return {
service: resolved?.pdsUrl ?? defaultService,
did: resolved?.did ?? null,
}
},
}
}
+6 -1
View File
@@ -160,7 +160,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/> />
) : undefined} ) : undefined}
{screenState === ScreenState.S_Login ? ( {screenState === ScreenState.S_Login ? (
<Login onPressBack={onPressBack} /> <Login
onPressBack={onPressBack}
onPressCreateAccount={() => {
setScreenState(ScreenState.S_CreateAccount)
}}
/>
) : undefined} ) : undefined}
{screenState === ScreenState.S_CreateAccount ? ( {screenState === ScreenState.S_CreateAccount ? (
<Signup onPressBack={onPressBack} /> <Signup onPressBack={onPressBack} />
+13 -14
View File
@@ -80,17 +80,6 @@ export const SplashScreen = ({
<View <View
testID="signinOrCreateAccount" testID="signinOrCreateAccount"
style={[a.px_5xl, a.gap_md, a.pb_sm]}> style={[a.px_5xl, a.gap_md, a.pb_sm]}>
<View
style={[
t.atoms.shadow_md,
{
shadowOpacity: 0.1,
shadowOffset: {
width: 0,
height: 5,
},
},
]}>
<Button <Button
testID="createAccountButton" testID="createAccountButton"
onPress={() => { onPress={() => {
@@ -102,12 +91,21 @@ export const SplashScreen = ({
msg`Opens flow to create a new Bluesky account`, msg`Opens flow to create a new Bluesky account`,
)} )}
size="large" size="large"
color={isDarkMode ? 'secondary_inverted' : 'secondary'}> color={isDarkMode ? 'secondary_inverted' : 'secondary'}
style={[
t.atoms.shadow_md,
{
shadowOpacity: 0.1,
shadowOffset: {
width: 0,
height: 5,
},
},
]}>
<ButtonText> <ButtonText>
<Trans>Create account</Trans> <Trans>Create account</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
</View>
<Button <Button
testID="signInButton" testID="signInButton"
@@ -119,7 +117,8 @@ export const SplashScreen = ({
accessibilityHint={_( accessibilityHint={_(
msg`Opens flow to sign in to your existing Bluesky account`, msg`Opens flow to sign in to your existing Bluesky account`,
)} )}
size="large"> size="large"
hoverStyle={{opacity: 0.5}}>
<ButtonText style={{color: 'white'}}> <ButtonText style={{color: 'white'}}>
<Trans>Sign in</Trans> <Trans>Sign in</Trans>
</ButtonText> </ButtonText>
+11 -8
View File
@@ -1,7 +1,6 @@
import {ScrollView, StyleSheet, View} from 'react-native' import {ScrollView, StyleSheet, View} from 'react-native'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -29,8 +28,6 @@ export const LoggedOutLayout = ({
borderLeftWidth: 1, borderLeftWidth: 1,
}) })
const [isKeyboardVisible] = useIsKeyboardVisible()
if (isMobile) { if (isMobile) {
if (scrollable) { if (scrollable) {
return ( return (
@@ -38,10 +35,8 @@ export const LoggedOutLayout = ({
style={a.flex_1} style={a.flex_1}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="none" keyboardDismissMode="none"
contentContainerStyle={[ contentContainerStyle={[a.flex_grow]}>
{paddingBottom: isKeyboardVisible ? 300 : 0}, <View style={[a.flex_1, a.pt_lg]}>{children}</View>
]}>
<View style={a.pt_lg}>{children}</View>
</ScrollView> </ScrollView>
) )
} else { } else {
@@ -77,7 +72,15 @@ export const LoggedOutLayout = ({
style={a.flex_1} style={a.flex_1}
contentContainerStyle={styles.scrollViewContentContainer} contentContainerStyle={styles.scrollViewContentContainer}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"> /*
* RNW implements `on-drag` by blurring the focused element on ANY
* scroll event - including the one Firefox fires when swapping
* splash -> login content resizes the scroller - which kills the
* login form's autofocus. It doesn't appear to do anything anyways
* on web (judging by iOS safari, which keeps the keyboard open
* regardless of scrolling) -sfn
*/
keyboardDismissMode={IS_WEB ? 'none' : 'on-drag'}>
<View style={[styles.contentWrapper, IS_WEB && a.my_auto]}> <View style={[styles.contentWrapper, IS_WEB && a.my_auto]}>
{children} {children}
</View> </View>