Bluesky app fork with some witchin' additions 馃挮
at main 388 lines 14 kB view raw
1import {useCallback, useRef, useState} from 'react' 2import {ActivityIndicator, Keyboard, type TextInput, View} from 'react-native' 3import { 4 ComAtprotoServerCreateSession, 5 type ComAtprotoServerDescribeServer, 6} from '@atproto/api' 7import {msg} from '@lingui/core/macro' 8import {useLingui} from '@lingui/react' 9import {Trans} from '@lingui/react/macro' 10 11import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 12import {cleanError, isNetworkError} from '#/lib/strings/errors' 13import {createFullHandle} from '#/lib/strings/handles' 14import {isValidDomain} from '#/lib/strings/url-helpers' 15import {logger} from '#/logger' 16import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 17import {useSessionApi} from '#/state/session' 18import {useLoggedOutViewControls} from '#/state/shell/logged-out' 19import {atoms as a, ios, useTheme, web} from '#/alf' 20import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21import {FormError} from '#/components/forms/FormError' 22import {HostingProvider} from '#/components/forms/HostingProvider' 23import * as TextField from '#/components/forms/TextField' 24import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 25import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 26import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 27import {Loader} from '#/components/Loader' 28import {Text} from '#/components/Typography' 29import {IS_IOS, IS_WEB} from '#/env' 30import {FormContainer} from './FormContainer' 31 32type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 33 34export const LoginForm = ({ 35 error, 36 serviceUrl, 37 serviceDescription, 38 initialHandle, 39 setError, 40 setServiceUrl, 41 onPressRetryConnect, 42 onPressBack, 43 onPressForgotPassword, 44 onAttemptSuccess, 45 onAttemptFailed, 46 debouncedResolveService, 47 isResolvingService, 48}: { 49 error: string 50 serviceUrl?: string | undefined 51 serviceDescription: ServiceDescription | undefined 52 initialHandle: string 53 setError: (v: string) => void 54 setServiceUrl: (v: string) => void 55 onPressRetryConnect: () => void 56 onPressBack: () => void 57 onPressForgotPassword: () => void 58 onAttemptSuccess: () => void 59 onAttemptFailed: () => void 60 debouncedResolveService: (identifier: string) => void 61 isResolvingService: boolean 62}) => { 63 const t = useTheme() 64 const [isProcessing, setIsProcessing] = useState(false) 65 const [errorField, setErrorField] = useState< 66 'none' | 'identifier' | 'password' | '2fa' 67 >('none') 68 const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) 69 const identifierValueRef = useRef<string>(initialHandle || '') 70 const passwordValueRef = useRef<string>('') 71 const [authFactorToken, setAuthFactorToken] = useState('') 72 const identifierRef = useRef<TextInput>(null) 73 const passwordRef = useRef<TextInput>(null) 74 const hasFocusedOnce = useRef<boolean>(false) 75 const {_} = useLingui() 76 const {login} = useSessionApi() 77 const requestNotificationsPermission = useRequestNotificationsPermission() 78 const {setShowLoggedOut} = useLoggedOutViewControls() 79 const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 80 81 const onPressSelectService = useCallback(() => { 82 Keyboard.dismiss() 83 }, []) 84 85 const onPressNext = async () => { 86 if (isProcessing || isResolvingService || serviceUrl === undefined) return 87 Keyboard.dismiss() 88 setError('') 89 setErrorField('none') 90 91 const identifier = identifierValueRef.current.toLowerCase().trim() 92 const password = passwordValueRef.current 93 94 if (!identifier) { 95 setError(_(msg`Please enter your username`)) 96 setErrorField('identifier') 97 return 98 } 99 100 if (!password) { 101 setError(_(msg`Please enter your password`)) 102 return 103 } 104 105 setIsProcessing(true) 106 107 try { 108 // try to guess the handle if the user just gave their own username 109 let fullIdent = identifier 110 if ( 111 !identifier.includes('@') && // not an email 112 !identifier.includes('.') && // not a domain 113 serviceDescription && 114 serviceDescription.availableUserDomains.length > 0 115 ) { 116 let matched = false 117 for (const domain of serviceDescription.availableUserDomains) { 118 if (fullIdent.endsWith(domain)) { 119 matched = true 120 } 121 } 122 if (!matched) { 123 fullIdent = createFullHandle( 124 identifier, 125 serviceDescription.availableUserDomains[0], 126 ) 127 } 128 } 129 130 // TODO remove double login 131 await login( 132 { 133 service: serviceUrl, 134 identifier: fullIdent, 135 password, 136 authFactorToken: authFactorToken.trim(), 137 }, 138 'LoginForm', 139 ) 140 onAttemptSuccess() 141 setShowLoggedOut(false) 142 setHasCheckedForStarterPack(true) 143 requestNotificationsPermission('Login') 144 } catch (e: any) { 145 const errMsg = e.toString() 146 setIsProcessing(false) 147 if ( 148 e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError 149 ) { 150 setIsAuthFactorTokenNeeded(true) 151 } else { 152 onAttemptFailed() 153 if (errMsg.includes('Token is invalid')) { 154 logger.debug('Failed to login due to invalid 2fa token', { 155 error: errMsg, 156 }) 157 setError(_(msg`Invalid 2FA confirmation code.`)) 158 setErrorField('2fa') 159 } else if ( 160 errMsg.includes('Authentication Required') || 161 errMsg.includes('Invalid identifier or password') 162 ) { 163 logger.debug('Failed to login due to invalid credentials', { 164 error: errMsg, 165 }) 166 setError(_(msg`Incorrect username or password`)) 167 } else if (isNetworkError(e)) { 168 logger.warn('Failed to login due to network error', {error: errMsg}) 169 setError( 170 _( 171 msg`Unable to contact your service. Please check your Internet connection.`, 172 ), 173 ) 174 } else { 175 logger.warn('Failed to login', {error: errMsg}) 176 setError(cleanError(errMsg)) 177 } 178 } 179 } 180 } 181 182 return ( 183 <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 184 <View> 185 <TextField.LabelText> 186 <Trans>Hosting provider</Trans> 187 {isResolvingService && ( 188 <ActivityIndicator 189 size={10} 190 color={t.palette.contrast_500} 191 style={a.ml_sm} 192 /> 193 )} 194 </TextField.LabelText> 195 <HostingProvider 196 serviceUrl={serviceUrl} 197 onSelectServiceUrl={setServiceUrl} 198 onOpenDialog={onPressSelectService} 199 /> 200 </View> 201 <View> 202 <TextField.LabelText> 203 <Trans>Account</Trans> 204 </TextField.LabelText> 205 <View style={[a.gap_sm]}> 206 <TextField.Root isInvalid={errorField === 'identifier'}> 207 <TextField.Icon icon={At} /> 208 <TextField.Input 209 testID="loginUsernameInput" 210 inputRef={identifierRef} 211 label={ 212 serviceUrl === undefined ? _(msg`Username (full handle)`) : 213 _(msg`Username or email address`) 214 } 215 autoCapitalize="none" 216 autoFocus={!IS_IOS} 217 autoCorrect={false} 218 autoComplete="username" 219 returnKeyType="next" 220 textContentType="username" 221 defaultValue={initialHandle || ''} 222 onChangeText={v => { 223 identifierValueRef.current = v 224 // Trigger PDS auto-resolution for handles/DIDs 225 const id = v.trim() 226 if (!id) return 227 if ( 228 id.startsWith('did:') || 229 (!id.includes('@') && isValidDomain(id)) 230 ) { 231 debouncedResolveService(id) 232 } 233 if (errorField) setErrorField('none') 234 }} 235 onSubmitEditing={() => { 236 passwordRef.current?.focus() 237 }} 238 blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 239 editable={!isProcessing} 240 accessibilityHint={_( 241 msg`Enter the username or email address you used when you created your account`, 242 )} 243 /> 244 </TextField.Root> 245 246 <TextField.Root isInvalid={errorField === 'password'}> 247 <TextField.Icon icon={Lock} /> 248 <TextField.Input 249 testID="loginPasswordInput" 250 inputRef={passwordRef} 251 label={_(msg`Password`)} 252 autoCapitalize="none" 253 autoCorrect={false} 254 autoComplete="current-password" 255 returnKeyType="done" 256 enablesReturnKeyAutomatically={true} 257 secureTextEntry={true} 258 clearButtonMode="while-editing" 259 onChangeText={v => { 260 passwordValueRef.current = v 261 if (errorField) setErrorField('none') 262 }} 263 onSubmitEditing={onPressNext} 264 blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing 265 editable={!isProcessing} 266 accessibilityHint={_(msg`Enter your password`)} 267 onLayout={ios(() => { 268 if (hasFocusedOnce.current) return 269 hasFocusedOnce.current = true 270 // kinda dumb, but if we use `autoFocus` to focus 271 // the username input, it happens before the password 272 // input gets rendered. this breaks the password autofill 273 // on iOS (it only does the username part). delaying 274 // it until both inputs are rendered fixes the autofill -sfn 275 identifierRef.current?.focus() 276 })} 277 /> 278 <Button 279 testID="forgotPasswordButton" 280 onPress={onPressForgotPassword} 281 label={_(msg`Forgot password?`)} 282 accessibilityHint={_(msg`Opens password reset form`)} 283 variant="solid" 284 color="secondary" 285 style={[ 286 a.rounded_sm, 287 // t.atoms.bg_contrast_100, 288 {marginLeft: 'auto', left: 6, padding: 6}, 289 a.z_10, 290 ]}> 291 <ButtonText> 292 <Trans>Forgot?</Trans> 293 </ButtonText> 294 </Button> 295 </TextField.Root> 296 </View> 297 </View> 298 {isAuthFactorTokenNeeded && ( 299 <View> 300 <TextField.LabelText> 301 <Trans>2FA Confirmation</Trans> 302 </TextField.LabelText> 303 <TextField.Root isInvalid={errorField === '2fa'}> 304 <TextField.Icon icon={Ticket} /> 305 <TextField.Input 306 testID="loginAuthFactorTokenInput" 307 label={_(msg`Confirmation code`)} 308 autoCapitalize="none" 309 autoFocus 310 autoCorrect={false} 311 autoComplete="one-time-code" 312 returnKeyType="done" 313 blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 314 value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly 315 onChangeText={text => { 316 setAuthFactorToken(text) 317 if (errorField) setErrorField('none') 318 }} 319 onSubmitEditing={onPressNext} 320 editable={!isProcessing} 321 accessibilityHint={_( 322 msg`Input the code which has been emailed to you`, 323 )} 324 style={{ 325 textTransform: authFactorToken === '' ? 'none' : 'uppercase', 326 }} 327 /> 328 </TextField.Root> 329 <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}> 330 <Trans> 331 Check your email for a sign in code and enter it here. 332 </Trans> 333 </Text> 334 </View> 335 )} 336 <FormError error={error} /> 337 <View style={[a.pt_md, web([a.justify_between, a.flex_row])]}> 338 {IS_WEB && ( 339 <Button 340 label={_(msg`Back`)} 341 color="secondary" 342 size="large" 343 onPress={onPressBack}> 344 <ButtonText> 345 <Trans>Back</Trans> 346 </ButtonText> 347 </Button> 348 )} 349 {!serviceDescription && error ? ( 350 <Button 351 testID="loginRetryButton" 352 label={_(msg`Retry`)} 353 accessibilityHint={_(msg`Retries signing in`)} 354 color="primary_subtle" 355 size="large" 356 onPress={onPressRetryConnect}> 357 <ButtonText> 358 <Trans>Retry</Trans> 359 </ButtonText> 360 </Button> 361 ) : !serviceDescription && serviceUrl !== undefined ? ( 362 <Button 363 label={_(msg`Connecting to service...`)} 364 size="large" 365 color="secondary" 366 disabled> 367 <ButtonIcon icon={Loader} /> 368 <ButtonText>Connecting...</ButtonText> 369 </Button> 370 ) : ( 371 <Button 372 testID="loginNextButton" 373 label={_(msg`Sign in`)} 374 accessibilityHint={_(msg`Navigates to the next screen`)} 375 color="primary" 376 size="large" 377 onPress={onPressNext} 378 disabled={isResolvingService || serviceUrl === undefined}> 379 <ButtonText> 380 <Trans>Sign in</Trans> 381 </ButtonText> 382 {isProcessing && <ButtonIcon icon={Loader} />} 383 </Button> 384 )} 385 </View> 386 </FormContainer> 387 ) 388}