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:
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 couldn’t 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 don’t 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 can’t 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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user