···6666 }
6767 }
68686969+ if (raw.includes('OAuth credentials are not supported')) {
7070+ return {
7171+ raw,
7272+ clean: _(
7373+ msg`This feature is not available when signed in with OAuth. Please manage your account through your hosting provider's website.`,
7474+ ),
7575+ }
7676+ }
7777+6978 if (raw.includes('Rate Limit Exceeded')) {
7079 return {
7180 raw,
···3030 if (str.includes('Bad token scope') || str.includes('Bad token method')) {
3131 return t`This feature is not available while using an App Password. Please sign in with your main password.`
3232 }
3333+ if (str.includes('OAuth credentials are not supported')) {
3434+ return t`This feature is not available when signed in with OAuth. Please manage your account through your hosting provider's website.`
3535+ }
3636+ if (
3737+ str.includes('ScopeMissingError') ||
3838+ str.includes('Missing required scope')
3939+ ) {
4040+ return t`This feature is not available with your current session. Please manage your account through your hosting provider's website, or sign out and sign back in to refresh your permissions.`
4141+ }
3342 if (str.includes('Account has been suspended')) {
3443 return t`Account has been suspended`
3544 }
···11+import {useRef, useState} from 'react'
22+import {Keyboard, LayoutAnimation, View} from 'react-native'
33+import {type ComAtprotoServerDescribeServer} from '@atproto/api'
44+import {msg} from '@lingui/core/macro'
55+import {useLingui} from '@lingui/react'
66+import {Trans} from '@lingui/react/macro'
77+88+import {cleanError, isNetworkError} from '#/lib/strings/errors'
99+import {logger} from '#/logger'
1010+import {getWebOAuthClient} from '#/state/session/oauth-web-client'
1111+import {atoms as a} from '#/alf'
1212+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
1313+import {FormError} from '#/components/forms/FormError'
1414+import * as TextField from '#/components/forms/TextField'
1515+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
1616+import {Loader} from '#/components/Loader'
1717+import {FormContainer} from './FormContainer'
1818+1919+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
2020+2121+/**
2222+ * Web-specific LoginForm that uses OAuth handle-only flow.
2323+ * On web, users enter their handle and are redirected to their PDS
2424+ * authorization server for approval.
2525+ *
2626+ * Accepts the same props as the native LoginForm for compatibility with
2727+ * Login/index.tsx, but only uses a subset of them.
2828+ */
2929+export const LoginForm = ({
3030+ error,
3131+ initialHandle,
3232+ setError,
3333+ onPressBack,
3434+}: {
3535+ error: string
3636+ serviceUrl?: string | undefined
3737+ serviceDescription: ServiceDescription | undefined
3838+ initialHandle: string
3939+ setError: (v: string) => void
4040+ setServiceUrl: (v: string) => void
4141+ onPressRetryConnect: () => void
4242+ onPressBack: () => void
4343+ onPressForgotPassword: () => void
4444+ onAttemptSuccess: () => void
4545+ onAttemptFailed: () => void
4646+ debouncedResolveService: (identifier: string) => void
4747+ isResolvingService: boolean
4848+}) => {
4949+ const [isProcessing, setIsProcessing] = useState<boolean>(false)
5050+ const identifierValueRef = useRef<string>(initialHandle || '')
5151+ const {_} = useLingui()
5252+5353+ const onPressNext = async () => {
5454+ if (isProcessing) return
5555+ Keyboard.dismiss()
5656+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
5757+ setError('')
5858+5959+ const identifier = identifierValueRef.current.trim()
6060+6161+ if (!identifier) {
6262+ setError(_(msg`Please enter your username or handle`))
6363+ return
6464+ }
6565+6666+ setIsProcessing(true)
6767+6868+ try {
6969+ const client = getWebOAuthClient()
7070+ await client.signIn(identifier)
7171+ // Browser will redirect to authorization server
7272+ } catch (e: any) {
7373+ const errMsg = e.toString()
7474+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
7575+ setIsProcessing(false)
7676+ if (isNetworkError(e)) {
7777+ logger.warn('Failed to start OAuth sign-in due to network error', {
7878+ error: errMsg,
7979+ })
8080+ setError(
8181+ _(
8282+ msg`Unable to contact your service. Please check your Internet connection.`,
8383+ ),
8484+ )
8585+ } else {
8686+ logger.warn('Failed to start OAuth sign-in', {error: errMsg})
8787+ setError(cleanError(errMsg))
8888+ }
8989+ }
9090+ }
9191+9292+ return (
9393+ <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
9494+ <View>
9595+ <TextField.LabelText>
9696+ <Trans>Account</Trans>
9797+ </TextField.LabelText>
9898+ <View style={[a.gap_sm]}>
9999+ <TextField.Root>
100100+ <TextField.Icon icon={At} />
101101+ <TextField.Input
102102+ testID="loginUsernameInput"
103103+ label={_(msg`Username or handle`)}
104104+ autoCapitalize="none"
105105+ autoFocus
106106+ autoCorrect={false}
107107+ autoComplete="username"
108108+ returnKeyType="done"
109109+ textContentType="username"
110110+ defaultValue={initialHandle || ''}
111111+ onChangeText={v => {
112112+ identifierValueRef.current = v
113113+ }}
114114+ onSubmitEditing={onPressNext}
115115+ blurOnSubmit={false}
116116+ editable={!isProcessing}
117117+ accessibilityHint={_(
118118+ msg`Enter your handle (e.g. alice.bsky.social)`,
119119+ )}
120120+ />
121121+ </TextField.Root>
122122+ </View>
123123+ </View>
124124+ <FormError error={error} />
125125+ <View style={[a.flex_row, a.align_center, a.pt_md]}>
126126+ <Button
127127+ label={_(msg`Back`)}
128128+ variant="solid"
129129+ color="secondary"
130130+ size="large"
131131+ onPress={onPressBack}>
132132+ <ButtonText>
133133+ <Trans>Back</Trans>
134134+ </ButtonText>
135135+ </Button>
136136+ <View style={a.flex_1} />
137137+ <Button
138138+ testID="loginNextButton"
139139+ label={_(msg`Sign in`)}
140140+ accessibilityHint={_(msg`Redirects to your authorization server`)}
141141+ color="primary"
142142+ size="large"
143143+ onPress={onPressNext}>
144144+ <ButtonText>
145145+ <Trans>Sign in</Trans>
146146+ </ButtonText>
147147+ {isProcessing && <ButtonIcon icon={Loader} />}
148148+ </Button>
149149+ </View>
150150+ </FormContainer>
151151+ )
152152+}
+4-1
src/screens/Login/index.tsx
···2020import {atoms as a, native} from '#/alf'
2121import {ScreenTransition} from '#/components/ScreenTransition'
2222import {useAnalytics} from '#/analytics'
2323+import {IS_WEB} from '#/env'
2324import {ChooseAccountForm} from './ChooseAccountForm'
2425import * as AuthLayout from './components/AuthLayout'
2526import {AuthLayoutNavigationContext} from './components/AuthLayout/context'
···186187 switch (currentForm) {
187188 case Forms.Login:
188189 title = _(msg`Sign in`)
189189- description = _(msg`Enter your username and password`)
190190+ description = IS_WEB
191191+ ? _(msg`Enter your handle to sign in`)
192192+ : _(msg`Enter your username and password`)
190193 goBack = () =>
191194 accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
192195 content = (
···11+import {type Agent} from '@atproto/api'
12import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'
2334import {IS_TEST_USER} from '#/lib/constants'
···1415}
15161617export async function configureModerationForAccount(
1717- agent: BskyAgent,
1818+ agent: Agent | BskyAgent,
1819 account: SessionAccount,
1920) {
2021 // This global mutation is *only* OK because this code is only relevant for testing.
···4445 })
4546}
46474747-async function trySwitchToTestAppLabeler(agent: BskyAgent) {
4848+async function trySwitchToTestAppLabeler(agent: Agent | BskyAgent) {
4849 const did = (
4950 await agent
5051 .resolveHandle({handle: 'mod-authority.test'})
···1010// A hack so that the reducer can't read anything from the agent.
1111// From the reducer's point of view, it should be a completely opaque object.
1212type OpaqueBskyAgent = {
1313- readonly service: URL
1313+ readonly service?: URL | undefined
1414 readonly api: unknown
1515 readonly app: unknown
1616 readonly com: unknown