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