From a9ab13e5a936c4d917b878bd53f4e536fa8c95f8 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 6 Feb 2024 10:06:25 -0800 Subject: 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 --- src/lib/strings/password.ts | 19 ++ src/state/modals/index.tsx | 5 + src/view/com/auth/login/ForgotPasswordForm.tsx | 23 ++ src/view/com/auth/login/SetNewPasswordForm.tsx | 36 ++- src/view/com/modals/ChangePassword.tsx | 336 +++++++++++++++++++++++++ src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + src/view/screens/Settings.tsx | 29 ++- 8 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 src/lib/strings/password.ts create mode 100644 src/view/com/modals/ChangePassword.tsx (limited to 'src') diff --git a/src/lib/strings/password.ts b/src/lib/strings/password.ts new file mode 100644 index 000000000..e7735b90e --- /dev/null +++ b/src/lib/strings/password.ts @@ -0,0 +1,19 @@ +// Regex for base32 string for testing reset code +const RESET_CODE_REGEX = /^[A-Z2-7]{5}-[A-Z2-7]{5}$/ + +export function checkAndFormatResetCode(code: string): string | false { + // Trim the reset code + let fixed = code.trim().toUpperCase() + + // Add a dash if needed + if (fixed.length === 10) { + fixed = `${fixed.slice(0, 5)}-${fixed.slice(5, 10)}` + } + + // Check that it is a valid format + if (!RESET_CODE_REGEX.test(fixed)) { + return false + } + + return fixed +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index ab710a3d0..e3a4ccd8c 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -171,6 +171,10 @@ export interface ChangeEmailModal { name: 'change-email' } +export interface ChangePasswordModal { + name: 'change-password' +} + export interface SwitchAccountModal { name: 'switch-account' } @@ -202,6 +206,7 @@ export type Modal = | BirthDateSettingsModal | VerifyEmailModal | ChangeEmailModal + | ChangePasswordModal | SwitchAccountModal // Curation diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx index f9bb64f98..79399d85d 100644 --- a/src/view/com/auth/login/ForgotPasswordForm.tsx +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -195,6 +195,29 @@ export const ForgotPasswordForm = ({ ) : undefined} + + + + + Already have a code? + + + ) diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx index 630c6afde..6d1584c86 100644 --- a/src/view/com/auth/login/SetNewPasswordForm.tsx +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -14,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' +import {checkAndFormatResetCode} from 'lib/strings/password' import {logger} from '#/logger' import {styles} from './styles' import {Trans, msg} from '@lingui/macro' @@ -46,14 +47,26 @@ export const SetNewPasswordForm = ({ const [password, setPassword] = useState('') const onPressNext = async () => { + // Check that the code is correct. We do this again just incase the user enters the code after their pw and we + // don't get to call onBlur first + const formattedCode = checkAndFormatResetCode(resetCode) + // TODO Better password strength check + if (!formattedCode || !password) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setError('') setIsProcessing(true) try { const agent = new BskyAgent({service: serviceUrl}) - const token = resetCode.replace(/\s/g, '') await agent.com.atproto.server.resetPassword({ - token, + token: formattedCode, password, }) onPasswordSet() @@ -71,6 +84,19 @@ export const SetNewPasswordForm = ({ } } + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setResetCode(formattedCode) + } + return ( <> @@ -100,9 +126,11 @@ export const SetNewPasswordForm = ({ autoCapitalize="none" autoCorrect={false} keyboardAppearance={theme.colorScheme} - autoFocus + autoComplete="off" value={resetCode} onChangeText={setResetCode} + onFocus={() => setError('')} + onBlur={onBlur} editable={!isProcessing} accessible={true} accessibilityLabel={_(msg`Reset code`)} @@ -123,6 +151,7 @@ export const SetNewPasswordForm = ({ placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoCorrect={false} + autoComplete="new-password" keyboardAppearance={theme.colorScheme} secureTextEntry value={password} @@ -160,6 +189,7 @@ export const SetNewPasswordForm = ({ ) : ( (Stages.RequestCode) + const [isProcessing, setIsProcessing] = useState(false) + const [resetCode, setResetCode] = useState('') + const [newPassword, setNewPassword] = useState('') + const [error, setError] = useState('') + const {isMobile} = useWebMediaQueries() + const {closeModal} = useModalControls() + const agent = getAgent() + + const onRequestCode = async () => { + if ( + !currentAccount?.email || + !EmailValidator.validate(currentAccount.email) + ) { + return setError(_(msg`Your email appears to be invalid.`)) + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.requestPasswordReset({ + email: currentAccount.email, + }) + setStage(Stages.ChangePassword) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + setIsProcessing(false) + } + } + + const onChangePassword = async () => { + const formattedCode = checkAndFormatResetCode(resetCode) + // TODO Better password strength check + if (!formattedCode || !newPassword) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.resetPassword({ + token: formattedCode, + password: newPassword, + }) + setStage(Stages.Done) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + setIsProcessing(false) + } + } + + const onBlur = () => { + const formattedCode = checkAndFormatResetCode(resetCode) + if (!formattedCode) { + setError( + _( + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, + ), + ) + return + } + setResetCode(formattedCode) + } + + return ( + + + + + + {stage !== Stages.Done ? 'Change Password' : 'Password Changed'} + + + + + {stage === Stages.RequestCode ? ( + + If you want to change your password, we will send you a code to + verify that this is your account. + + ) : stage === Stages.ChangePassword ? ( + + Enter the code you received to change your password. + + ) : ( + Your password has been changed successfully! + )} + + + {stage === Stages.RequestCode && ( + + setStage(Stages.ChangePassword)} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint={_(msg`Navigates to the next screen`)}> + + Already have a code? + + + + )} + {stage === Stages.ChangePassword && ( + + + + setError('')} + onBlur={onBlur} + accessible={true} + accessibilityLabel={_(msg`Reset Code`)} + accessibilityHint="" + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + /> + + + + + + + )} + {error ? ( + + ) : undefined} + + + {isProcessing ? ( + + + + ) : ( + + {stage === Stages.RequestCode && ( +