about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-02-06 10:06:25 -0800
committerGitHub <noreply@github.com>2024-02-06 10:06:25 -0800
commita9ab13e5a936c4d917b878bd53f4e536fa8c95f8 (patch)
tree1f739d6ef6b33849accd1f3bc6780c0704d8e2fd /src
parentb9e00afdb1a5b80b7a440c19af335ffbec1f3753 (diff)
downloadvoidsky-a9ab13e5a936c4d917b878bd53f4e536fa8c95f8.tar.zst
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>
Diffstat (limited to 'src')
-rw-r--r--src/lib/strings/password.ts19
-rw-r--r--src/state/modals/index.tsx5
-rw-r--r--src/view/com/auth/login/ForgotPasswordForm.tsx23
-rw-r--r--src/view/com/auth/login/SetNewPasswordForm.tsx36
-rw-r--r--src/view/com/modals/ChangePassword.tsx336
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/screens/Settings.tsx29
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} />