An ATproto social media client -- with an independent Appview.

password flow improvements (#2730)

* add button to skip sending reset code

* add validation to reset code

* comments

* update test id

* consistency sneak in - everything capitalized

* add change password button to settings

* create a modal for password change

* change password modal

* remove unused styles

* more improvements

* improve layout

* change done button color

* add already have a code to modal

* remove unused prop

* icons, auto add dash

* cleanup

* better appearance on android

* Remove log

* Improve error messages and add specificity to function names

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by hailey.at

Paul Frazee and committed by
GitHub
a9ab13e5 b9e00afd

+448 -7
+19
src/lib/strings/password.ts
··· 1 + // Regex for base32 string for testing reset code 2 + const RESET_CODE_REGEX = /^[A-Z2-7]{5}-[A-Z2-7]{5}$/ 3 + 4 + export function checkAndFormatResetCode(code: string): string | false { 5 + // Trim the reset code 6 + let fixed = code.trim().toUpperCase() 7 + 8 + // Add a dash if needed 9 + if (fixed.length === 10) { 10 + fixed = `${fixed.slice(0, 5)}-${fixed.slice(5, 10)}` 11 + } 12 + 13 + // Check that it is a valid format 14 + if (!RESET_CODE_REGEX.test(fixed)) { 15 + return false 16 + } 17 + 18 + return fixed 19 + }
+5
src/state/modals/index.tsx
··· 171 171 name: 'change-email' 172 172 } 173 173 174 + export interface ChangePasswordModal { 175 + name: 'change-password' 176 + } 177 + 174 178 export interface SwitchAccountModal { 175 179 name: 'switch-account' 176 180 } ··· 202 206 | BirthDateSettingsModal 203 207 | VerifyEmailModal 204 208 | ChangeEmailModal 209 + | ChangePasswordModal 205 210 | SwitchAccountModal 206 211 207 212 // Curation
+23
src/view/com/auth/login/ForgotPasswordForm.tsx
··· 195 195 </Text> 196 196 ) : undefined} 197 197 </View> 198 + <View 199 + style={[ 200 + s.flexRow, 201 + s.alignCenter, 202 + s.mt20, 203 + s.mb20, 204 + pal.border, 205 + s.borderBottom1, 206 + {alignSelf: 'center', width: '90%'}, 207 + ]} 208 + /> 209 + <View style={[s.flexRow, s.justifyCenter]}> 210 + <TouchableOpacity 211 + testID="skipSendEmailButton" 212 + onPress={onEmailSent} 213 + accessibilityRole="button" 214 + accessibilityLabel={_(msg`Go to next`)} 215 + accessibilityHint={_(msg`Navigates to the next screen`)}> 216 + <Text type="xl" style={[pal.link, s.pr5]}> 217 + <Trans>Already have a code?</Trans> 218 + </Text> 219 + </TouchableOpacity> 220 + </View> 198 221 </View> 199 222 </> 200 223 )
+33 -3
src/view/com/auth/login/SetNewPasswordForm.tsx
··· 14 14 import {usePalette} from 'lib/hooks/usePalette' 15 15 import {useTheme} from 'lib/ThemeContext' 16 16 import {cleanError} from 'lib/strings/errors' 17 + import {checkAndFormatResetCode} from 'lib/strings/password' 17 18 import {logger} from '#/logger' 18 19 import {styles} from './styles' 19 20 import {Trans, msg} from '@lingui/macro' ··· 46 47 const [password, setPassword] = useState<string>('') 47 48 48 49 const onPressNext = async () => { 50 + // Check that the code is correct. We do this again just incase the user enters the code after their pw and we 51 + // don't get to call onBlur first 52 + const formattedCode = checkAndFormatResetCode(resetCode) 53 + // TODO Better password strength check 54 + if (!formattedCode || !password) { 55 + setError( 56 + _( 57 + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 58 + ), 59 + ) 60 + return 61 + } 62 + 49 63 setError('') 50 64 setIsProcessing(true) 51 65 52 66 try { 53 67 const agent = new BskyAgent({service: serviceUrl}) 54 - const token = resetCode.replace(/\s/g, '') 55 68 await agent.com.atproto.server.resetPassword({ 56 - token, 69 + token: formattedCode, 57 70 password, 58 71 }) 59 72 onPasswordSet() ··· 71 84 } 72 85 } 73 86 87 + const onBlur = () => { 88 + const formattedCode = checkAndFormatResetCode(resetCode) 89 + if (!formattedCode) { 90 + setError( 91 + _( 92 + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 93 + ), 94 + ) 95 + return 96 + } 97 + setResetCode(formattedCode) 98 + } 99 + 74 100 return ( 75 101 <> 76 102 <View> ··· 100 126 autoCapitalize="none" 101 127 autoCorrect={false} 102 128 keyboardAppearance={theme.colorScheme} 103 - autoFocus 129 + autoComplete="off" 104 130 value={resetCode} 105 131 onChangeText={setResetCode} 132 + onFocus={() => setError('')} 133 + onBlur={onBlur} 106 134 editable={!isProcessing} 107 135 accessible={true} 108 136 accessibilityLabel={_(msg`Reset code`)} ··· 123 151 placeholderTextColor={pal.colors.textLight} 124 152 autoCapitalize="none" 125 153 autoCorrect={false} 154 + autoComplete="new-password" 126 155 keyboardAppearance={theme.colorScheme} 127 156 secureTextEntry 128 157 value={password} ··· 160 189 ) : ( 161 190 <TouchableOpacity 162 191 testID="setNewPasswordButton" 192 + // Check the code before running the callback 163 193 onPress={onPressNext} 164 194 accessibilityRole="button" 165 195 accessibilityLabel={_(msg`Go to next`)}
+336
src/view/com/modals/ChangePassword.tsx
··· 1 + import React, {useState} from 'react' 2 + import { 3 + ActivityIndicator, 4 + SafeAreaView, 5 + StyleSheet, 6 + TouchableOpacity, 7 + View, 8 + } from 'react-native' 9 + import {ScrollView} from './util' 10 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 + import {TextInput} from './util' 12 + import {Text} from '../util/text/Text' 13 + import {Button} from '../util/forms/Button' 14 + import {ErrorMessage} from '../util/error/ErrorMessage' 15 + import {s, colors} from 'lib/styles' 16 + import {usePalette} from 'lib/hooks/usePalette' 17 + import {isAndroid, isWeb} from 'platform/detection' 18 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 19 + import {cleanError, isNetworkError} from 'lib/strings/errors' 20 + import {checkAndFormatResetCode} from 'lib/strings/password' 21 + import {Trans, msg} from '@lingui/macro' 22 + import {useLingui} from '@lingui/react' 23 + import {useModalControls} from '#/state/modals' 24 + import {useSession, getAgent} from '#/state/session' 25 + import * as EmailValidator from 'email-validator' 26 + import {logger} from '#/logger' 27 + 28 + enum Stages { 29 + RequestCode, 30 + ChangePassword, 31 + Done, 32 + } 33 + 34 + export const snapPoints = isAndroid ? ['90%'] : ['45%'] 35 + 36 + export function Component() { 37 + const pal = usePalette('default') 38 + const {currentAccount} = useSession() 39 + const {_} = useLingui() 40 + const [stage, setStage] = useState<Stages>(Stages.RequestCode) 41 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 42 + const [resetCode, setResetCode] = useState<string>('') 43 + const [newPassword, setNewPassword] = useState<string>('') 44 + const [error, setError] = useState<string>('') 45 + const {isMobile} = useWebMediaQueries() 46 + const {closeModal} = useModalControls() 47 + const agent = getAgent() 48 + 49 + const onRequestCode = async () => { 50 + if ( 51 + !currentAccount?.email || 52 + !EmailValidator.validate(currentAccount.email) 53 + ) { 54 + return setError(_(msg`Your email appears to be invalid.`)) 55 + } 56 + 57 + setError('') 58 + setIsProcessing(true) 59 + try { 60 + await agent.com.atproto.server.requestPasswordReset({ 61 + email: currentAccount.email, 62 + }) 63 + setStage(Stages.ChangePassword) 64 + } catch (e: any) { 65 + const errMsg = e.toString() 66 + logger.warn('Failed to request password reset', {error: e}) 67 + if (isNetworkError(e)) { 68 + setError( 69 + _( 70 + msg`Unable to contact your service. Please check your Internet connection.`, 71 + ), 72 + ) 73 + } else { 74 + setError(cleanError(errMsg)) 75 + } 76 + } finally { 77 + setIsProcessing(false) 78 + } 79 + } 80 + 81 + const onChangePassword = async () => { 82 + const formattedCode = checkAndFormatResetCode(resetCode) 83 + // TODO Better password strength check 84 + if (!formattedCode || !newPassword) { 85 + setError( 86 + _( 87 + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 88 + ), 89 + ) 90 + return 91 + } 92 + 93 + setError('') 94 + setIsProcessing(true) 95 + try { 96 + await agent.com.atproto.server.resetPassword({ 97 + token: formattedCode, 98 + password: newPassword, 99 + }) 100 + setStage(Stages.Done) 101 + } catch (e: any) { 102 + const errMsg = e.toString() 103 + logger.warn('Failed to set new password', {error: e}) 104 + if (isNetworkError(e)) { 105 + setError( 106 + 'Unable to contact your service. Please check your Internet connection.', 107 + ) 108 + } else { 109 + setError(cleanError(errMsg)) 110 + } 111 + } finally { 112 + setIsProcessing(false) 113 + } 114 + } 115 + 116 + const onBlur = () => { 117 + const formattedCode = checkAndFormatResetCode(resetCode) 118 + if (!formattedCode) { 119 + setError( 120 + _( 121 + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 122 + ), 123 + ) 124 + return 125 + } 126 + setResetCode(formattedCode) 127 + } 128 + 129 + return ( 130 + <SafeAreaView style={[pal.view, s.flex1]}> 131 + <ScrollView 132 + contentContainerStyle={[ 133 + styles.container, 134 + isMobile && styles.containerMobile, 135 + ]} 136 + keyboardShouldPersistTaps="handled"> 137 + <View> 138 + <View style={styles.titleSection}> 139 + <Text type="title-lg" style={[pal.text, styles.title]}> 140 + {stage !== Stages.Done ? 'Change Password' : 'Password Changed'} 141 + </Text> 142 + </View> 143 + 144 + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> 145 + {stage === Stages.RequestCode ? ( 146 + <Trans> 147 + If you want to change your password, we will send you a code to 148 + verify that this is your account. 149 + </Trans> 150 + ) : stage === Stages.ChangePassword ? ( 151 + <Trans> 152 + Enter the code you received to change your password. 153 + </Trans> 154 + ) : ( 155 + <Trans>Your password has been changed successfully!</Trans> 156 + )} 157 + </Text> 158 + 159 + {stage === Stages.RequestCode && ( 160 + <View style={[s.flexRow, s.justifyCenter, s.mt10]}> 161 + <TouchableOpacity 162 + testID="skipSendEmailButton" 163 + onPress={() => setStage(Stages.ChangePassword)} 164 + accessibilityRole="button" 165 + accessibilityLabel={_(msg`Go to next`)} 166 + accessibilityHint={_(msg`Navigates to the next screen`)}> 167 + <Text type="xl" style={[pal.link, s.pr5]}> 168 + <Trans>Already have a code?</Trans> 169 + </Text> 170 + </TouchableOpacity> 171 + </View> 172 + )} 173 + {stage === Stages.ChangePassword && ( 174 + <View style={[pal.border, styles.group]}> 175 + <View style={[styles.groupContent]}> 176 + <FontAwesomeIcon 177 + icon="ticket" 178 + style={[pal.textLight, styles.groupContentIcon]} 179 + /> 180 + <TextInput 181 + testID="codeInput" 182 + style={[pal.text, styles.textInput]} 183 + placeholder="Reset code" 184 + placeholderTextColor={pal.colors.textLight} 185 + value={resetCode} 186 + onChangeText={setResetCode} 187 + onFocus={() => setError('')} 188 + onBlur={onBlur} 189 + accessible={true} 190 + accessibilityLabel={_(msg`Reset Code`)} 191 + accessibilityHint="" 192 + autoCapitalize="none" 193 + autoCorrect={false} 194 + autoComplete="off" 195 + /> 196 + </View> 197 + <View 198 + style={[ 199 + pal.borderDark, 200 + styles.groupContent, 201 + styles.groupBottom, 202 + ]}> 203 + <FontAwesomeIcon 204 + icon="lock" 205 + style={[pal.textLight, styles.groupContentIcon]} 206 + /> 207 + <TextInput 208 + testID="codeInput" 209 + style={[pal.text, styles.textInput]} 210 + placeholder="New password" 211 + placeholderTextColor={pal.colors.textLight} 212 + onChangeText={setNewPassword} 213 + secureTextEntry 214 + accessible={true} 215 + accessibilityLabel={_(msg`New Password`)} 216 + accessibilityHint="" 217 + autoCapitalize="none" 218 + autoComplete="new-password" 219 + /> 220 + </View> 221 + </View> 222 + )} 223 + {error ? ( 224 + <ErrorMessage message={error} style={styles.error} /> 225 + ) : undefined} 226 + </View> 227 + <View style={[styles.btnContainer]}> 228 + {isProcessing ? ( 229 + <View style={styles.btn}> 230 + <ActivityIndicator color="#fff" /> 231 + </View> 232 + ) : ( 233 + <View style={{gap: 6}}> 234 + {stage === Stages.RequestCode && ( 235 + <Button 236 + testID="requestChangeBtn" 237 + type="primary" 238 + onPress={onRequestCode} 239 + accessibilityLabel={_(msg`Request Code`)} 240 + accessibilityHint="" 241 + label={_(msg`Request Code`)} 242 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 243 + labelStyle={[s.f18]} 244 + /> 245 + )} 246 + {stage === Stages.ChangePassword && ( 247 + <Button 248 + testID="confirmBtn" 249 + type="primary" 250 + onPress={onChangePassword} 251 + accessibilityLabel={_(msg`Next`)} 252 + accessibilityHint="" 253 + label={_(msg`Next`)} 254 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 255 + labelStyle={[s.f18]} 256 + /> 257 + )} 258 + <Button 259 + testID="cancelBtn" 260 + type={stage !== Stages.Done ? 'default' : 'primary'} 261 + onPress={() => { 262 + closeModal() 263 + }} 264 + accessibilityLabel={ 265 + stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`) 266 + } 267 + accessibilityHint="" 268 + label={stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)} 269 + labelContainerStyle={{justifyContent: 'center', padding: 4}} 270 + labelStyle={[s.f18]} 271 + /> 272 + </View> 273 + )} 274 + </View> 275 + </ScrollView> 276 + </SafeAreaView> 277 + ) 278 + } 279 + 280 + const styles = StyleSheet.create({ 281 + container: { 282 + justifyContent: 'space-between', 283 + }, 284 + containerMobile: { 285 + paddingHorizontal: 18, 286 + paddingBottom: 35, 287 + }, 288 + titleSection: { 289 + paddingTop: isWeb ? 0 : 4, 290 + paddingBottom: isWeb ? 14 : 10, 291 + }, 292 + title: { 293 + textAlign: 'center', 294 + fontWeight: '600', 295 + marginBottom: 5, 296 + }, 297 + error: { 298 + borderRadius: 6, 299 + }, 300 + textInput: { 301 + width: '100%', 302 + paddingHorizontal: 14, 303 + paddingVertical: 10, 304 + fontSize: 16, 305 + }, 306 + btn: { 307 + flexDirection: 'row', 308 + alignItems: 'center', 309 + justifyContent: 'center', 310 + borderRadius: 32, 311 + padding: 14, 312 + backgroundColor: colors.blue3, 313 + }, 314 + btnContainer: { 315 + paddingTop: 20, 316 + }, 317 + group: { 318 + borderWidth: 1, 319 + borderRadius: 10, 320 + marginVertical: 20, 321 + }, 322 + groupLabel: { 323 + paddingHorizontal: 20, 324 + paddingBottom: 5, 325 + }, 326 + groupContent: { 327 + flexDirection: 'row', 328 + alignItems: 'center', 329 + }, 330 + groupBottom: { 331 + borderTopWidth: 1, 332 + }, 333 + groupContentIcon: { 334 + marginLeft: 10, 335 + }, 336 + })
+4
src/view/com/modals/Modal.tsx
··· 36 36 import * as BirthDateSettingsModal from './BirthDateSettings' 37 37 import * as VerifyEmailModal from './VerifyEmail' 38 38 import * as ChangeEmailModal from './ChangeEmail' 39 + import * as ChangePasswordModal from './ChangePassword' 39 40 import * as SwitchAccountModal from './SwitchAccount' 40 41 import * as LinkWarningModal from './LinkWarning' 41 42 import * as EmbedConsentModal from './EmbedConsent' ··· 172 173 } else if (activeModal?.name === 'change-email') { 173 174 snapPoints = ChangeEmailModal.snapPoints 174 175 element = <ChangeEmailModal.Component /> 176 + } else if (activeModal?.name === 'change-password') { 177 + snapPoints = ChangePasswordModal.snapPoints 178 + element = <ChangePasswordModal.Component /> 175 179 } else if (activeModal?.name === 'switch-account') { 176 180 snapPoints = SwitchAccountModal.snapPoints 177 181 element = <SwitchAccountModal.Component />
+3
src/view/com/modals/Modal.web.tsx
··· 34 34 import * as BirthDateSettingsModal from './BirthDateSettings' 35 35 import * as VerifyEmailModal from './VerifyEmail' 36 36 import * as ChangeEmailModal from './ChangeEmail' 37 + import * as ChangePasswordModal from './ChangePassword' 37 38 import * as LinkWarningModal from './LinkWarning' 38 39 import * as EmbedConsentModal from './EmbedConsent' 39 40 ··· 134 135 element = <VerifyEmailModal.Component {...modal} /> 135 136 } else if (modal.name === 'change-email') { 136 137 element = <ChangeEmailModal.Component /> 138 + } else if (modal.name === 'change-password') { 139 + element = <ChangePasswordModal.Component /> 137 140 } else if (modal.name === 'link-warning') { 138 141 element = <LinkWarningModal.Component {...modal} /> 139 142 } else if (modal.name === 'embed-consent') {
+25 -4
src/view/screens/Settings.tsx
··· 647 647 /> 648 648 </View> 649 649 <Text type="lg" style={pal.text}> 650 - <Trans>App passwords</Trans> 650 + <Trans>App Passwords</Trans> 651 651 </Text> 652 652 </TouchableOpacity> 653 653 <TouchableOpacity ··· 668 668 /> 669 669 </View> 670 670 <Text type="lg" style={pal.text} numberOfLines={1}> 671 - <Trans>Change handle</Trans> 671 + <Trans>Change Handle</Trans> 672 672 </Text> 673 673 </TouchableOpacity> 674 674 {isNative && ( ··· 684 684 )} 685 685 <View style={styles.spacer20} /> 686 686 <Text type="xl-bold" style={[pal.text, styles.heading]}> 687 - <Trans>Danger Zone</Trans> 687 + <Trans>Account</Trans> 688 688 </Text> 689 689 <TouchableOpacity 690 + testID="changePasswordBtn" 691 + style={[ 692 + styles.linkCard, 693 + pal.view, 694 + isSwitchingAccounts && styles.dimmed, 695 + ]} 696 + onPress={() => openModal({name: 'change-password'})} 697 + accessibilityRole="button" 698 + accessibilityLabel={_(msg`Change password`)} 699 + accessibilityHint={_(msg`Change your Bluesky password`)}> 700 + <View style={[styles.iconContainer, pal.btn]}> 701 + <FontAwesomeIcon 702 + icon="lock" 703 + style={pal.text as FontAwesomeIconStyle} 704 + /> 705 + </View> 706 + <Text type="lg" style={pal.text} numberOfLines={1}> 707 + <Trans>Change Password</Trans> 708 + </Text> 709 + </TouchableOpacity> 710 + <TouchableOpacity 690 711 style={[pal.view, styles.linkCard]} 691 712 onPress={onPressDeleteAccount} 692 713 accessible={true} ··· 703 724 /> 704 725 </View> 705 726 <Text type="lg" style={dangerText}> 706 - <Trans>Delete my account…</Trans> 727 + <Trans>Delete My Account…</Trans> 707 728 </Text> 708 729 </TouchableOpacity> 709 730 <View style={styles.spacer20} />