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