import React, {useRef, useState} from 'react' import { ActivityIndicator, Keyboard, LayoutAnimation, TextInput, View, } from 'react-native' import { ComAtprotoServerCreateSession, ComAtprotoServerDescribeServer, } from '@atproto/api' import { AuthorizeOptions, BrowserOAuthClient, LoginContinuedInParentWindowError, OAuthAgent, } from '@atproto/oauth-client-browser' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' import {isNetworkError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' import {useGate} from 'lib/statsig/statsig' import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' import * as TextField from '#/components/forms/TextField' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema const client = new BrowserOAuthClient({ plcDirectoryUrl: 'https://plc.directory', handleResolver: 'https://bsky.social', }) export function useOAuth(client: BrowserOAuthClient) { const [agent, setAgent] = useState(null) const [loading, setLoading] = useState(true) const clientRef = useRef() React.useEffect(() => { // In strict mode, we don't want to reinitialize the client if it's the same if (clientRef.current === client) return clientRef.current = client setLoading(true) setAgent(null) client .init() .then(async r => { if (clientRef.current !== client) return setAgent(r?.agent || null) }) .catch(err => { console.error('Failed to init:', err) if (clientRef.current !== client) return if (err instanceof LoginContinuedInParentWindowError) return setAgent(null) }) .finally(() => { if (clientRef.current !== client) return setLoading(false) }) }, [client]) React.useEffect(() => { if (!agent) return const clear = ({detail}: {detail: {sub: string}}) => { if (detail.sub === agent.sub) { setAgent(null) } } client.addEventListener('deleted', clear) return () => { client.removeEventListener('deleted', clear) } }, [client, agent]) const signOut = React.useCallback(async () => { if (!agent) return setAgent(null) setLoading(true) try { await agent.signOut() } catch (err) { console.error('Failed to clear credentials', err) throw err } finally { setLoading(false) } }, [agent]) const signIn = React.useCallback( async (input: string, options?: AuthorizeOptions) => { // if (agent) return setLoading(true) try { const agent = await client.signIn(input, options) setAgent(agent) } catch (err) { console.error('Failed to login', err) throw err } finally { setLoading(false) } }, [client], ) return { agent, loading, signedIn: agent != null, signIn, signOut, } } export const LoginForm = ({ error, serviceUrl, serviceDescription, initialHandle, setError, setServiceUrl, onPressRetryConnect, onPressBack, onPressForgotPassword, }: { error: string serviceUrl: string serviceDescription: ServiceDescription | undefined initialHandle: string setError: (v: string) => void setServiceUrl: (v: string) => void onPressRetryConnect: () => void onPressBack: () => void onPressForgotPassword: () => void }) => { const {track} = useAnalytics() const t = useTheme() const [isProcessing, setIsProcessing] = useState(false) const [isReady, setIsReady] = useState(false) const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) const identifierValueRef = useRef(initialHandle || '') const passwordValueRef = useRef('') const authFactorTokenValueRef = useRef('') const passwordRef = useRef(null) const {_} = useLingui() const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() const {setShowLoggedOut} = useLoggedOutViewControls() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const gate = useGate() const oauthEnabled = __DEV__ || gate('use_oauth') const oauth = useOAuth(client) const [handle, setHandle] = React.useState('') const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() track('Signin:PressedSelectService') }, [track]) const onPressNextOauth = async () => { await oauth.signIn(handle) } const onPressNext = async () => { if (isProcessing) return Keyboard.dismiss() LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) setError('') setIsProcessing(true) const identifier = identifierValueRef.current.toLowerCase().trim() const password = passwordValueRef.current const authFactorToken = authFactorTokenValueRef.current try { // try to guess the handle if the user just gave their own username let fullIdent = identifier if ( !identifier.includes('@') && // not an email !identifier.includes('.') && // not a domain serviceDescription && serviceDescription.availableUserDomains.length > 0 ) { let matched = false for (const domain of serviceDescription.availableUserDomains) { if (fullIdent.endsWith(domain)) { matched = true } } if (!matched) { fullIdent = createFullHandle( identifier, serviceDescription.availableUserDomains[0], ) } } // TODO remove double login await login( { service: serviceUrl, identifier: fullIdent, password, authFactorToken: authFactorToken.trim(), }, 'LoginForm', ) setShowLoggedOut(false) setHasCheckedForStarterPack(true) requestNotificationsPermission('Login') } catch (e: any) { const errMsg = e.toString() LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) setIsProcessing(false) if ( e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError ) { setIsAuthFactorTokenNeeded(true) } else if (errMsg.includes('Token is invalid')) { logger.debug('Failed to login due to invalid 2fa token', { error: errMsg, }) setError(_(msg`Invalid 2FA confirmation code.`)) } else if (errMsg.includes('Authentication Required')) { logger.debug('Failed to login due to invalid credentials', { error: errMsg, }) setError(_(msg`Invalid 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)) } } } const checkIsReady = () => { if ( !!serviceDescription && !!identifierValueRef.current && !!passwordValueRef.current ) { if (!isReady) { setIsReady(true) } } else { if (isReady) { setIsReady(false) } } } return ( Sign in}> {oauthEnabled ? ( <> Handle { setHandle(v) checkIsReady() }} onSubmitEditing={() => { // @TODO submit }} blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field editable={!isProcessing} accessibilityHint={_(msg`Input the handle for your account`)} /> ) : ( <> Hosting provider Account { identifierValueRef.current = v checkIsReady() }} onSubmitEditing={() => { passwordRef.current?.focus() }} blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field editable={!isProcessing} accessibilityHint={_( msg`Input the username or email address you used at signup`, )} /> { passwordValueRef.current = v checkIsReady() }} onSubmitEditing={onPressNext} blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing editable={!isProcessing} accessibilityHint={_(msg`Input your password`)} /> )} {isAuthFactorTokenNeeded && ( 2FA Confirmation { authFactorTokenValueRef.current = v }} onSubmitEditing={onPressNext} editable={!isProcessing} accessibilityHint={_( msg`Input the code which has been emailed to you`, )} /> Check your email for a login code and enter it here. )} {!serviceDescription && error ? ( ) : !serviceDescription ? ( <> Connecting... ) : isReady || oauthEnabled ? ( ) : undefined} ) }