forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useRef, useState} from 'react'
2import {
3 ActivityIndicator,
4 Keyboard,
5 Pressable,
6 type TextInput,
7 View,
8} from 'react-native'
9import {
10 ComAtprotoServerCreateSession,
11 type ComAtprotoServerDescribeServer,
12} from '@atproto/api'
13import {msg} from '@lingui/core/macro'
14import {useLingui} from '@lingui/react'
15import {Trans} from '@lingui/react/macro'
16
17import {cleanError, isNetworkError} from '#/lib/strings/errors'
18import {createFullHandle} from '#/lib/strings/handles'
19import {isValidDomain} from '#/lib/strings/url-helpers'
20import {logger} from '#/logger'
21import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
22import {useSessionApi} from '#/state/session'
23import {getWebOAuthClient} from '#/state/session/oauth-web-client'
24import {useLoggedOutViewControls} from '#/state/shell/logged-out'
25import {atoms as a, useTheme} from '#/alf'
26import {Button, ButtonIcon, ButtonText} from '#/components/Button'
27import {FormError} from '#/components/forms/FormError'
28import {HostingProvider} from '#/components/forms/HostingProvider'
29import * as TextField from '#/components/forms/TextField'
30import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
31import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
32import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
33import {Loader} from '#/components/Loader'
34import {Text} from '#/components/Typography'
35import {FormContainer} from './FormContainer'
36
37type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
38
39type LoginMode = 'oauth' | 'legacy'
40
41/**
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
45 */
46export const LoginForm = ({
47 error,
48 serviceUrl,
49 serviceDescription,
50 initialHandle,
51 setError,
52 setServiceUrl,
53 onPressRetryConnect,
54 onPressBack,
55 onPressForgotPassword,
56 onAttemptSuccess,
57 onAttemptFailed,
58 debouncedResolveService,
59 isResolvingService,
60}: {
61 error: string
62 serviceUrl?: string | undefined
63 serviceDescription: ServiceDescription | undefined
64 initialHandle: string
65 setError: (v: string) => void
66 setServiceUrl: (v: string) => void
67 onPressRetryConnect: () => void
68 onPressBack: () => void
69 onPressForgotPassword: () => void
70 onAttemptSuccess: () => void
71 onAttemptFailed: () => void
72 debouncedResolveService: (identifier: string) => void
73 isResolvingService: boolean
74}) => {
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
174function 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}) {
189 const {_} = useLingui()
190 const identifierValueRef = useRef<string>(initialHandle || '')
191
192 const onPressNext = async () => {
193 if (isProcessing) return
194 Keyboard.dismiss()
195 setError('')
196
197 const identifier = identifierValueRef.current.trim()
198
199 if (!identifier) {
200 setError(_(msg`Please enter your username or handle`))
201 return
202 }
203
204 setIsProcessing(true)
205
206 try {
207 const client = getWebOAuthClient()
208 await client.signIn(identifier)
209 // Browser will redirect to authorization server
210 } catch (e: any) {
211 const errMsg = e.toString()
212 setIsProcessing(false)
213 if (isNetworkError(e)) {
214 logger.warn('Failed to start OAuth sign-in due to network error', {
215 error: errMsg,
216 })
217 setError(
218 _(
219 msg`Unable to contact your service. Please check your Internet connection.`,
220 ),
221 )
222 } else {
223 logger.warn('Failed to start OAuth sign-in', {error: errMsg})
224 setError(cleanError(errMsg))
225 }
226 }
227 }
228
229 return (
230 <>
231 <View>
232 <TextField.LabelText>
233 <Trans>Account</Trans>
234 </TextField.LabelText>
235 <View style={[a.gap_sm]}>
236 <TextField.Root>
237 <TextField.Icon icon={At} />
238 <TextField.Input
239 testID="loginUsernameInput"
240 label={_(msg`Username or handle`)}
241 autoCapitalize="none"
242 autoFocus
243 autoCorrect={false}
244 autoComplete="username"
245 returnKeyType="done"
246 textContentType="username"
247 defaultValue={initialHandle || ''}
248 onChangeText={v => {
249 identifierValueRef.current = v
250 }}
251 onSubmitEditing={onPressNext}
252 blurOnSubmit={false}
253 editable={!isProcessing}
254 accessibilityHint={_(
255 msg`Enter your handle (e.g. alice.bsky.social)`,
256 )}
257 />
258 </TextField.Root>
259 </View>
260 </View>
261 <FormError error={error} />
262 <View style={[a.flex_row, a.align_center, a.pt_md]}>
263 <Button
264 label={_(msg`Back`)}
265 variant="solid"
266 color="secondary"
267 size="large"
268 onPress={onPressBack}>
269 <ButtonText>
270 <Trans>Back</Trans>
271 </ButtonText>
272 </Button>
273 <View style={a.flex_1} />
274 <Button
275 testID="loginNextButton"
276 label={_(msg`Sign in`)}
277 accessibilityHint={_(msg`Redirects to your authorization server`)}
278 color="primary"
279 size="large"
280 onPress={onPressNext}>
281 <ButtonText>
282 <Trans>Sign in</Trans>
283 </ButtonText>
284 {isProcessing && <ButtonIcon icon={Loader} />}
285 </Button>
286 </View>
287 </>
288 )
289}
290
291function 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 </>
630 )
631}