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