Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add service sign-in option as "Legacy Sign-in"

authored by

uwx and committed by tangled.org fe788646 faa6e3bb

+495 -16
+495 -16
src/screens/Login/LoginForm.web.tsx
··· 1 - import {useRef, useState} from 'react' 2 - import {Keyboard, LayoutAnimation, View} from 'react-native' 3 - import {type ComAtprotoServerDescribeServer} from '@atproto/api' 1 + import {useCallback, useRef, useState} from 'react' 2 + import { 3 + ActivityIndicator, 4 + Keyboard, 5 + Pressable, 6 + type TextInput, 7 + View, 8 + } from 'react-native' 9 + import { 10 + ComAtprotoServerCreateSession, 11 + type ComAtprotoServerDescribeServer, 12 + } from '@atproto/api' 4 13 import {msg} from '@lingui/core/macro' 5 14 import {useLingui} from '@lingui/react' 6 15 import {Trans} from '@lingui/react/macro' 7 16 8 17 import {cleanError, isNetworkError} from '#/lib/strings/errors' 18 + import {createFullHandle} from '#/lib/strings/handles' 19 + import {isValidDomain} from '#/lib/strings/url-helpers' 9 20 import {logger} from '#/logger' 21 + import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 22 + import {useSessionApi} from '#/state/session' 10 23 import {getWebOAuthClient} from '#/state/session/oauth-web-client' 11 - import {atoms as a} from '#/alf' 24 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 25 + import {atoms as a, useTheme} from '#/alf' 12 26 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 27 import {FormError} from '#/components/forms/FormError' 28 + import {HostingProvider} from '#/components/forms/HostingProvider' 14 29 import * as TextField from '#/components/forms/TextField' 15 30 import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 31 + import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 32 + import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 16 33 import {Loader} from '#/components/Loader' 34 + import {Text} from '#/components/Typography' 17 35 import {FormContainer} from './FormContainer' 18 36 19 37 type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 38 + 39 + type LoginMode = 'oauth' | 'legacy' 20 40 21 41 /** 22 - * Web-specific LoginForm that uses OAuth handle-only flow. 23 - * On web, users enter their handle and are redirected to their PDS 24 - * authorization server for approval. 25 - * 26 - * Accepts the same props as the native LoginForm for compatibility with 27 - * Login/index.tsx, but only uses a subset of them. 42 + * Web-specific LoginForm with two tabs: 43 + * - OAuth (default): handle-only flow, redirects to PDS authorization server 44 + * - Legacy sign-in: username + password, for operations that may not support OAuth 28 45 */ 29 46 export const LoginForm = ({ 30 47 error, 48 + serviceUrl, 49 + serviceDescription, 31 50 initialHandle, 32 51 setError, 52 + setServiceUrl, 53 + onPressRetryConnect, 33 54 onPressBack, 55 + onPressForgotPassword, 56 + onAttemptSuccess, 57 + onAttemptFailed, 58 + debouncedResolveService, 59 + isResolvingService, 34 60 }: { 35 61 error: string 36 62 serviceUrl?: string | undefined ··· 46 72 debouncedResolveService: (identifier: string) => void 47 73 isResolvingService: boolean 48 74 }) => { 49 - const [isProcessing, setIsProcessing] = useState<boolean>(false) 50 - const identifierValueRef = useRef<string>(initialHandle || '') 75 + const t = useTheme() 76 + const [mode, setMode] = useState<LoginMode>('oauth') 77 + const [isProcessing, setIsProcessing] = useState(false) 78 + 79 + const switchMode = (next: LoginMode) => { 80 + if (next === mode) return 81 + setError('') 82 + setMode(next) 83 + } 84 + 85 + return ( 86 + <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 87 + <View 88 + style={[a.flex_row, a.mb_md, a.rounded_sm, a.overflow_hidden]} 89 + accessibilityRole="tablist"> 90 + <Pressable 91 + accessibilityRole="tab" 92 + accessibilityState={{selected: mode === 'oauth'}} 93 + onPress={() => switchMode('oauth')} 94 + style={[ 95 + a.flex_1, 96 + a.align_center, 97 + a.py_sm, 98 + a.border_b, 99 + {borderBottomWidth: 2}, 100 + mode === 'oauth' 101 + ? {borderBottomColor: t.palette.primary_500} 102 + : {borderBottomColor: 'transparent'}, 103 + ]}> 104 + <Text 105 + style={[ 106 + a.text_sm, 107 + a.font_bold, 108 + mode === 'oauth' 109 + ? {color: t.palette.primary_500} 110 + : t.atoms.text_contrast_medium, 111 + ]}> 112 + <Trans>OAuth</Trans> 113 + </Text> 114 + </Pressable> 115 + <Pressable 116 + accessibilityRole="tab" 117 + accessibilityState={{selected: mode === 'legacy'}} 118 + onPress={() => switchMode('legacy')} 119 + style={[ 120 + a.flex_1, 121 + a.align_center, 122 + a.py_sm, 123 + a.border_b, 124 + {borderBottomWidth: 2}, 125 + mode === 'legacy' 126 + ? {borderBottomColor: t.palette.primary_500} 127 + : {borderBottomColor: 'transparent'}, 128 + ]}> 129 + <Text 130 + style={[ 131 + a.text_sm, 132 + a.font_bold, 133 + mode === 'legacy' 134 + ? {color: t.palette.primary_500} 135 + : t.atoms.text_contrast_medium, 136 + ]}> 137 + <Trans>Legacy sign-in</Trans> 138 + </Text> 139 + </Pressable> 140 + </View> 141 + 142 + {mode === 'oauth' ? ( 143 + <OAuthLoginFields 144 + error={error} 145 + initialHandle={initialHandle} 146 + setError={setError} 147 + isProcessing={isProcessing} 148 + setIsProcessing={setIsProcessing} 149 + onPressBack={onPressBack} 150 + /> 151 + ) : ( 152 + <LegacyLoginFields 153 + error={error} 154 + serviceUrl={serviceUrl} 155 + serviceDescription={serviceDescription} 156 + initialHandle={initialHandle} 157 + setError={setError} 158 + setServiceUrl={setServiceUrl} 159 + onPressRetryConnect={onPressRetryConnect} 160 + onPressBack={onPressBack} 161 + onPressForgotPassword={onPressForgotPassword} 162 + onAttemptSuccess={onAttemptSuccess} 163 + onAttemptFailed={onAttemptFailed} 164 + debouncedResolveService={debouncedResolveService} 165 + isResolvingService={isResolvingService} 166 + isProcessing={isProcessing} 167 + setIsProcessing={setIsProcessing} 168 + /> 169 + )} 170 + </FormContainer> 171 + ) 172 + } 173 + 174 + function OAuthLoginFields({ 175 + error, 176 + initialHandle, 177 + setError, 178 + isProcessing, 179 + setIsProcessing, 180 + onPressBack, 181 + }: { 182 + error: string 183 + initialHandle: string 184 + setError: (v: string) => void 185 + isProcessing: boolean 186 + setIsProcessing: (v: boolean) => void 187 + onPressBack: () => void 188 + }) { 51 189 const {_} = useLingui() 190 + const identifierValueRef = useRef<string>(initialHandle || '') 52 191 53 192 const onPressNext = async () => { 54 193 if (isProcessing) return 55 194 Keyboard.dismiss() 56 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 57 195 setError('') 58 196 59 197 const identifier = identifierValueRef.current.trim() ··· 71 209 // Browser will redirect to authorization server 72 210 } catch (e: any) { 73 211 const errMsg = e.toString() 74 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 75 212 setIsProcessing(false) 76 213 if (isNetworkError(e)) { 77 214 logger.warn('Failed to start OAuth sign-in due to network error', { ··· 90 227 } 91 228 92 229 return ( 93 - <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 230 + <> 94 231 <View> 95 232 <TextField.LabelText> 96 233 <Trans>Account</Trans> ··· 147 284 {isProcessing && <ButtonIcon icon={Loader} />} 148 285 </Button> 149 286 </View> 150 - </FormContainer> 287 + </> 288 + ) 289 + } 290 + 291 + function LegacyLoginFields({ 292 + error, 293 + serviceUrl, 294 + serviceDescription, 295 + initialHandle, 296 + setError, 297 + setServiceUrl, 298 + onPressRetryConnect, 299 + onPressBack, 300 + onPressForgotPassword, 301 + onAttemptSuccess, 302 + onAttemptFailed, 303 + debouncedResolveService, 304 + isResolvingService, 305 + isProcessing, 306 + setIsProcessing, 307 + }: { 308 + error: string 309 + serviceUrl?: string | undefined 310 + serviceDescription: ServiceDescription | undefined 311 + initialHandle: string 312 + setError: (v: string) => void 313 + setServiceUrl: (v: string) => void 314 + onPressRetryConnect: () => void 315 + onPressBack: () => void 316 + onPressForgotPassword: () => void 317 + onAttemptSuccess: () => void 318 + onAttemptFailed: () => void 319 + debouncedResolveService: (identifier: string) => void 320 + isResolvingService: boolean 321 + isProcessing: boolean 322 + setIsProcessing: (v: boolean) => void 323 + }) { 324 + const t = useTheme() 325 + const {_} = useLingui() 326 + const {login} = useSessionApi() 327 + const {setShowLoggedOut} = useLoggedOutViewControls() 328 + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 329 + 330 + const [errorField, setErrorField] = useState< 331 + 'none' | 'identifier' | 'password' | '2fa' 332 + >('none') 333 + const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) 334 + const identifierValueRef = useRef<string>(initialHandle || '') 335 + const passwordValueRef = useRef<string>('') 336 + const [authFactorToken, setAuthFactorToken] = useState('') 337 + const identifierRef = useRef<TextInput>(null) 338 + const passwordRef = useRef<TextInput>(null) 339 + 340 + const onPressSelectService = useCallback(() => { 341 + Keyboard.dismiss() 342 + }, []) 343 + 344 + const onPressNext = async () => { 345 + if (isProcessing || isResolvingService || serviceUrl === undefined) return 346 + Keyboard.dismiss() 347 + setError('') 348 + setErrorField('none') 349 + 350 + const identifier = identifierValueRef.current.toLowerCase().trim() 351 + const password = passwordValueRef.current 352 + 353 + if (!identifier) { 354 + setError(_(msg`Please enter your username`)) 355 + setErrorField('identifier') 356 + return 357 + } 358 + 359 + if (!password) { 360 + setError(_(msg`Please enter your password`)) 361 + return 362 + } 363 + 364 + setIsProcessing(true) 365 + 366 + try { 367 + let fullIdent = identifier 368 + if ( 369 + !identifier.includes('@') && 370 + !identifier.includes('.') && 371 + serviceDescription && 372 + serviceDescription.availableUserDomains.length > 0 373 + ) { 374 + let matched = false 375 + for (const domain of serviceDescription.availableUserDomains) { 376 + if (fullIdent.endsWith(domain)) { 377 + matched = true 378 + } 379 + } 380 + if (!matched) { 381 + fullIdent = createFullHandle( 382 + identifier, 383 + serviceDescription.availableUserDomains[0], 384 + ) 385 + } 386 + } 387 + 388 + await login( 389 + { 390 + service: serviceUrl, 391 + identifier: fullIdent, 392 + password, 393 + authFactorToken: authFactorToken.trim(), 394 + }, 395 + 'LoginForm', 396 + ) 397 + onAttemptSuccess() 398 + setShowLoggedOut(false) 399 + setHasCheckedForStarterPack(true) 400 + } catch (e: any) { 401 + const errMsg = e.toString() 402 + setIsProcessing(false) 403 + if ( 404 + e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError 405 + ) { 406 + setIsAuthFactorTokenNeeded(true) 407 + } else { 408 + onAttemptFailed() 409 + if (errMsg.includes('Token is invalid')) { 410 + logger.debug('Failed to login due to invalid 2fa token', { 411 + error: errMsg, 412 + }) 413 + setError(_(msg`Invalid 2FA confirmation code.`)) 414 + setErrorField('2fa') 415 + } else if ( 416 + errMsg.includes('Authentication Required') || 417 + errMsg.includes('Invalid identifier or password') 418 + ) { 419 + logger.debug('Failed to login due to invalid credentials', { 420 + error: errMsg, 421 + }) 422 + setError(_(msg`Incorrect username or password`)) 423 + } else if (isNetworkError(e)) { 424 + logger.warn('Failed to login due to network error', {error: errMsg}) 425 + setError( 426 + _( 427 + msg`Unable to contact your service. Please check your Internet connection.`, 428 + ), 429 + ) 430 + } else { 431 + logger.warn('Failed to login', {error: errMsg}) 432 + setError(cleanError(errMsg)) 433 + } 434 + } 435 + } 436 + } 437 + 438 + return ( 439 + <> 440 + <View> 441 + <TextField.LabelText> 442 + <Trans>Hosting provider</Trans> 443 + {isResolvingService && ( 444 + <ActivityIndicator 445 + size={10} 446 + color={t.palette.contrast_500} 447 + style={a.ml_sm} 448 + /> 449 + )} 450 + </TextField.LabelText> 451 + <HostingProvider 452 + serviceUrl={serviceUrl} 453 + onSelectServiceUrl={setServiceUrl} 454 + onOpenDialog={onPressSelectService} 455 + /> 456 + </View> 457 + <View> 458 + <TextField.LabelText> 459 + <Trans>Account</Trans> 460 + </TextField.LabelText> 461 + <View style={[a.gap_sm]}> 462 + <TextField.Root isInvalid={errorField === 'identifier'}> 463 + <TextField.Icon icon={At} /> 464 + <TextField.Input 465 + testID="loginUsernameInput" 466 + inputRef={identifierRef} 467 + label={ 468 + serviceUrl === undefined 469 + ? _(msg`Username (full handle)`) 470 + : _(msg`Username or email address`) 471 + } 472 + autoCapitalize="none" 473 + autoFocus 474 + autoCorrect={false} 475 + autoComplete="username" 476 + returnKeyType="next" 477 + textContentType="username" 478 + defaultValue={initialHandle || ''} 479 + onChangeText={v => { 480 + identifierValueRef.current = v 481 + const id = v.trim() 482 + if (!id) return 483 + if ( 484 + id.startsWith('did:') || 485 + (!id.includes('@') && isValidDomain(id)) 486 + ) { 487 + debouncedResolveService(id) 488 + } 489 + if (errorField) setErrorField('none') 490 + }} 491 + onSubmitEditing={() => { 492 + passwordRef.current?.focus() 493 + }} 494 + blurOnSubmit={false} 495 + editable={!isProcessing} 496 + accessibilityHint={_( 497 + msg`Enter the username or email address you used when you created your account`, 498 + )} 499 + /> 500 + </TextField.Root> 501 + 502 + <TextField.Root isInvalid={errorField === 'password'}> 503 + <TextField.Icon icon={Lock} /> 504 + <TextField.Input 505 + testID="loginPasswordInput" 506 + inputRef={passwordRef} 507 + label={_(msg`Password`)} 508 + autoCapitalize="none" 509 + autoCorrect={false} 510 + autoComplete="current-password" 511 + returnKeyType="done" 512 + enablesReturnKeyAutomatically={true} 513 + secureTextEntry={true} 514 + clearButtonMode="while-editing" 515 + onChangeText={v => { 516 + passwordValueRef.current = v 517 + if (errorField) setErrorField('none') 518 + }} 519 + onSubmitEditing={onPressNext} 520 + blurOnSubmit={false} 521 + editable={!isProcessing} 522 + accessibilityHint={_(msg`Enter your password`)} 523 + /> 524 + <Button 525 + testID="forgotPasswordButton" 526 + onPress={onPressForgotPassword} 527 + label={_(msg`Forgot password?`)} 528 + accessibilityHint={_(msg`Opens password reset form`)} 529 + variant="solid" 530 + color="secondary" 531 + style={[ 532 + a.rounded_sm, 533 + {marginLeft: 'auto', left: 6, padding: 6}, 534 + a.z_10, 535 + ]}> 536 + <ButtonText> 537 + <Trans>Forgot?</Trans> 538 + </ButtonText> 539 + </Button> 540 + </TextField.Root> 541 + </View> 542 + </View> 543 + {isAuthFactorTokenNeeded && ( 544 + <View> 545 + <TextField.LabelText> 546 + <Trans>2FA Confirmation</Trans> 547 + </TextField.LabelText> 548 + <TextField.Root isInvalid={errorField === '2fa'}> 549 + <TextField.Icon icon={Ticket} /> 550 + <TextField.Input 551 + testID="loginAuthFactorTokenInput" 552 + label={_(msg`Confirmation code`)} 553 + autoCapitalize="none" 554 + autoFocus 555 + autoCorrect={false} 556 + autoComplete="one-time-code" 557 + returnKeyType="done" 558 + blurOnSubmit={false} 559 + value={authFactorToken} 560 + onChangeText={text => { 561 + setAuthFactorToken(text) 562 + if (errorField) setErrorField('none') 563 + }} 564 + onSubmitEditing={onPressNext} 565 + editable={!isProcessing} 566 + accessibilityHint={_( 567 + msg`Input the code which has been emailed to you`, 568 + )} 569 + style={{ 570 + textTransform: authFactorToken === '' ? 'none' : 'uppercase', 571 + }} 572 + /> 573 + </TextField.Root> 574 + <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}> 575 + <Trans> 576 + Check your email for a sign in code and enter it here. 577 + </Trans> 578 + </Text> 579 + </View> 580 + )} 581 + <FormError error={error} /> 582 + <View style={[a.pt_md, a.justify_between, a.flex_row]}> 583 + <Button 584 + label={_(msg`Back`)} 585 + color="secondary" 586 + size="large" 587 + onPress={onPressBack}> 588 + <ButtonText> 589 + <Trans>Back</Trans> 590 + </ButtonText> 591 + </Button> 592 + {!serviceDescription && error ? ( 593 + <Button 594 + testID="loginRetryButton" 595 + label={_(msg`Retry`)} 596 + accessibilityHint={_(msg`Retries signing in`)} 597 + color="primary_subtle" 598 + size="large" 599 + onPress={onPressRetryConnect}> 600 + <ButtonText> 601 + <Trans>Retry</Trans> 602 + </ButtonText> 603 + </Button> 604 + ) : !serviceDescription && serviceUrl !== undefined ? ( 605 + <Button 606 + label={_(msg`Connecting to service...`)} 607 + size="large" 608 + color="secondary" 609 + disabled> 610 + <ButtonIcon icon={Loader} /> 611 + <ButtonText>Connecting...</ButtonText> 612 + </Button> 613 + ) : ( 614 + <Button 615 + testID="loginNextButton" 616 + label={_(msg`Sign in`)} 617 + accessibilityHint={_(msg`Navigates to the next screen`)} 618 + color="primary" 619 + size="large" 620 + onPress={onPressNext} 621 + disabled={isResolvingService || serviceUrl === undefined}> 622 + <ButtonText> 623 + <Trans>Sign in</Trans> 624 + </ButtonText> 625 + {isProcessing && <ButtonIcon icon={Loader} />} 626 + </Button> 627 + )} 628 + </View> 629 + </> 151 630 ) 152 631 }