forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}