diff options
-rw-r--r-- | src/lib/strings/password.ts | 19 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 5 | ||||
-rw-r--r-- | src/view/com/auth/login/ForgotPasswordForm.tsx | 23 | ||||
-rw-r--r-- | src/view/com/auth/login/SetNewPasswordForm.tsx | 36 | ||||
-rw-r--r-- | src/view/com/modals/ChangePassword.tsx | 336 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 29 |
8 files changed, 448 insertions, 7 deletions
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 = ({ </Text> ) : undefined} </View> + <View + style={[ + s.flexRow, + s.alignCenter, + s.mt20, + s.mb20, + pal.border, + s.borderBottom1, + {alignSelf: 'center', width: '90%'}, + ]} + /> + <View style={[s.flexRow, s.justifyCenter]}> + <TouchableOpacity + testID="skipSendEmailButton" + onPress={onEmailSent} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint={_(msg`Navigates to the next screen`)}> + <Text type="xl" style={[pal.link, s.pr5]}> + <Trans>Already have a code?</Trans> + </Text> + </TouchableOpacity> + </View> </View> </> ) 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<string>('') 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 ( <> <View> @@ -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 = ({ ) : ( <TouchableOpacity testID="setNewPasswordButton" + // Check the code before running the callback onPress={onPressNext} accessibilityRole="button" accessibilityLabel={_(msg`Go to next`)} diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx new file mode 100644 index 000000000..d8add9794 --- /dev/null +++ b/src/view/com/modals/ChangePassword.tsx @@ -0,0 +1,336 @@ +import React, {useState} from 'react' +import { + ActivityIndicator, + SafeAreaView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {ScrollView} from './util' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {TextInput} from './util' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isAndroid, isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError, isNetworkError} from 'lib/strings/errors' +import {checkAndFormatResetCode} from 'lib/strings/password' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, getAgent} from '#/state/session' +import * as EmailValidator from 'email-validator' +import {logger} from '#/logger' + +enum Stages { + RequestCode, + ChangePassword, + Done, +} + +export const snapPoints = isAndroid ? ['90%'] : ['45%'] + +export function Component() { + const pal = usePalette('default') + const {currentAccount} = useSession() + const {_} = useLingui() + const [stage, setStage] = useState<Stages>(Stages.RequestCode) + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [newPassword, setNewPassword] = useState<string>('') + const [error, setError] = useState<string>('') + 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 ( + <SafeAreaView style={[pal.view, s.flex1]}> + <ScrollView + contentContainerStyle={[ + styles.container, + isMobile && styles.containerMobile, + ]} + keyboardShouldPersistTaps="handled"> + <View> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage !== Stages.Done ? 'Change Password' : 'Password Changed'} + </Text> + </View> + + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.RequestCode ? ( + <Trans> + If you want to change your password, we will send you a code to + verify that this is your account. + </Trans> + ) : stage === Stages.ChangePassword ? ( + <Trans> + Enter the code you received to change your password. + </Trans> + ) : ( + <Trans>Your password has been changed successfully!</Trans> + )} + </Text> + + {stage === Stages.RequestCode && ( + <View style={[s.flexRow, s.justifyCenter, s.mt10]}> + <TouchableOpacity + testID="skipSendEmailButton" + onPress={() => setStage(Stages.ChangePassword)} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint={_(msg`Navigates to the next screen`)}> + <Text type="xl" style={[pal.link, s.pr5]}> + <Trans>Already have a code?</Trans> + </Text> + </TouchableOpacity> + </View> + )} + {stage === Stages.ChangePassword && ( + <View style={[pal.border, styles.group]}> + <View style={[styles.groupContent]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="codeInput" + style={[pal.text, styles.textInput]} + placeholder="Reset code" + placeholderTextColor={pal.colors.textLight} + value={resetCode} + onChangeText={setResetCode} + onFocus={() => setError('')} + onBlur={onBlur} + accessible={true} + accessibilityLabel={_(msg`Reset Code`)} + accessibilityHint="" + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + /> + </View> + <View + style={[ + pal.borderDark, + styles.groupContent, + styles.groupBottom, + ]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="codeInput" + style={[pal.text, styles.textInput]} + placeholder="New password" + placeholderTextColor={pal.colors.textLight} + onChangeText={setNewPassword} + secureTextEntry + accessible={true} + accessibilityLabel={_(msg`New Password`)} + accessibilityHint="" + autoCapitalize="none" + autoComplete="new-password" + /> + </View> + </View> + )} + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + </View> + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.RequestCode && ( + <Button + testID="requestChangeBtn" + type="primary" + onPress={onRequestCode} + accessibilityLabel={_(msg`Request Code`)} + accessibilityHint="" + label={_(msg`Request Code`)} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.ChangePassword && ( + <Button + testID="confirmBtn" + type="primary" + onPress={onChangePassword} + accessibilityLabel={_(msg`Next`)} + accessibilityHint="" + label={_(msg`Next`)} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type={stage !== Stages.Done ? 'default' : 'primary'} + onPress={() => { + closeModal() + }} + accessibilityLabel={ + stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`) + } + accessibilityHint="" + label={stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> + ) +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'space-between', + }, + containerMobile: { + paddingHorizontal: 18, + paddingBottom: 35, + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + error: { + borderRadius: 6, + }, + textInput: { + width: '100%', + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginVertical: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + flexDirection: 'row', + alignItems: 'center', + }, + groupBottom: { + borderTopWidth: 1, + }, + groupContentIcon: { + marginLeft: 10, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 7f814d971..4aa10d75b 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -36,6 +36,7 @@ import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' +import * as ChangePasswordModal from './ChangePassword' import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' import * as EmbedConsentModal from './EmbedConsent' @@ -172,6 +173,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'change-email') { snapPoints = ChangeEmailModal.snapPoints element = <ChangeEmailModal.Component /> + } else if (activeModal?.name === 'change-password') { + snapPoints = ChangePasswordModal.snapPoints + element = <ChangePasswordModal.Component /> } else if (activeModal?.name === 'switch-account') { snapPoints = SwitchAccountModal.snapPoints element = <SwitchAccountModal.Component /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index d79663746..384a4772a 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -34,6 +34,7 @@ import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' +import * as ChangePasswordModal from './ChangePassword' import * as LinkWarningModal from './LinkWarning' import * as EmbedConsentModal from './EmbedConsent' @@ -134,6 +135,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <VerifyEmailModal.Component {...modal} /> } else if (modal.name === 'change-email') { element = <ChangeEmailModal.Component /> + } else if (modal.name === 'change-password') { + element = <ChangePasswordModal.Component /> } else if (modal.name === 'link-warning') { element = <LinkWarningModal.Component {...modal} /> } else if (modal.name === 'embed-consent') { diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 3b50c5449..17e4b45c5 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -647,7 +647,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={pal.text}> - <Trans>App passwords</Trans> + <Trans>App Passwords</Trans> </Text> </TouchableOpacity> <TouchableOpacity @@ -668,7 +668,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={pal.text} numberOfLines={1}> - <Trans>Change handle</Trans> + <Trans>Change Handle</Trans> </Text> </TouchableOpacity> {isNative && ( @@ -684,9 +684,30 @@ export function SettingsScreen({}: Props) { )} <View style={styles.spacer20} /> <Text type="xl-bold" style={[pal.text, styles.heading]}> - <Trans>Danger Zone</Trans> + <Trans>Account</Trans> </Text> <TouchableOpacity + testID="changePasswordBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={() => openModal({name: 'change-password'})} + accessibilityRole="button" + accessibilityLabel={_(msg`Change password`)} + accessibilityHint={_(msg`Change your Bluesky password`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="lock" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text} numberOfLines={1}> + <Trans>Change Password</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity style={[pal.view, styles.linkCard]} onPress={onPressDeleteAccount} accessible={true} @@ -703,7 +724,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={dangerText}> - <Trans>Delete my account…</Trans> + <Trans>Delete My Account…</Trans> </Text> </TouchableOpacity> <View style={styles.spacer20} /> |