From 70dbc94766b8f3c9d2c1b815fad66232523d28ab Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 23 Apr 2025 19:22:08 +0300 Subject: Modernise change email flow (#8106) * use new verify email dialog in 2fa flow * alf change email flow * Fallback change email dialog * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update ChangeEmailDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update Email2FAToggle.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * don't use existing email as default value * increase max width of email dialogs * Use ALF verify email dialog for reminder (#5924) * use new verify email dialog for reminder * style tweaks, improve web * add a lil toast * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Ditch close and push up image --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey * delete old change/verify email modals (#8122) (cherry picked from commit fceb655b3bacad1bce210810234137b7233d263d) * Translate email placeholder Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Align copy * Clean up error handling --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey --- src/Navigation.tsx | 72 +++-- src/components/dialogs/ChangeEmailDialog.tsx | 259 ++++++++++++++++ src/components/dialogs/VerifyEmailDialog.tsx | 159 +++++++--- src/screens/Settings/AccountSettings.tsx | 13 +- src/screens/Settings/Settings.tsx | 21 +- src/screens/Settings/components/Email2FAToggle.tsx | 30 +- src/state/modals/index.tsx | 12 - src/view/com/modals/ChangeEmail.tsx | 268 ---------------- src/view/com/modals/Modal.tsx | 8 - src/view/com/modals/Modal.web.tsx | 6 - src/view/com/modals/VerifyEmail.tsx | 342 --------------------- 11 files changed, 462 insertions(+), 728 deletions(-) create mode 100644 src/components/dialogs/ChangeEmailDialog.tsx delete mode 100644 src/view/com/modals/ChangeEmail.tsx delete mode 100644 src/view/com/modals/VerifyEmail.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index d4fdc4797..424d73290 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -33,7 +33,6 @@ import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {bskyTitle} from '#/lib/strings/headings' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' -import {useModalControls} from '#/state/modals' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useSession} from '#/state/session' import { @@ -80,33 +79,35 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed' import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' import {SearchScreen} from '#/screens/Search' +import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' +import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' +import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' +import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' +import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' +import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' +import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' +import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' +import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' +import {SettingsScreen} from '#/screens/Settings/Settings' import {SettingsInterests} from '#/screens/Settings/SettingsInterests' +import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' import { StarterPackScreen, StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' +import TopicScreen from '#/screens/Topic' import {VideoFeed} from '#/screens/VideoFeed' import {useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' -import {ProfileSearchScreen} from './screens/Profile/ProfileSearch' -import {AboutSettingsScreen} from './screens/Settings/AboutSettings' -import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' -import {AccountSettingsScreen} from './screens/Settings/AccountSettings' -import {AppPasswordsScreen} from './screens/Settings/AppPasswords' -import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' -import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' -import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' -import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' -import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' -import {SettingsScreen} from './screens/Settings/Settings' -import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' -import TopicScreen from './screens/Topic' const navigationRef = createNavigationContainerRef() @@ -736,36 +737,39 @@ const LINKING = { function RoutesContainer({children}: React.PropsWithChildren<{}>) { const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) const {currentAccount} = useSession() - const {openModal} = useModalControls() const prevLoggedRouteName = React.useRef(undefined) + const verifyEmailDialogControl = useDialogControl() function onReady() { prevLoggedRouteName.current = getCurrentRouteName() if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { - openModal({name: 'verify-email', showReminder: true}) + verifyEmailDialogControl.open() snoozeEmailConfirmationPrompt() } } return ( - { - logger.metric('router:navigate', { - from: prevLoggedRouteName.current, - }) - prevLoggedRouteName.current = getCurrentRouteName() - }} - onReady={() => { - attachRouteToLogEvents(getCurrentRouteName) - logModuleInitTime() - onReady() - logger.metric('router:navigate', {}) - }}> - {children} - + <> + { + logger.metric('router:navigate', { + from: prevLoggedRouteName.current, + }) + prevLoggedRouteName.current = getCurrentRouteName() + }} + onReady={() => { + attachRouteToLogEvents(getCurrentRouteName) + logModuleInitTime() + onReady() + logger.metric('router:navigate', {}) + }}> + {children} + + + ) } diff --git a/src/components/dialogs/ChangeEmailDialog.tsx b/src/components/dialogs/ChangeEmailDialog.tsx new file mode 100644 index 000000000..93397bae9 --- /dev/null +++ b/src/components/dialogs/ChangeEmailDialog.tsx @@ -0,0 +1,259 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {cleanError} from '#/lib/strings/errors' +import {useAgent, useSession} from '#/state/session' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {atoms as a, useBreakpoints, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function ChangeEmailDialog({ + control, + verifyEmailControl, +}: { + control: Dialog.DialogControlProps + verifyEmailControl: Dialog.DialogControlProps +}) { + return ( + + + + + ) +} + +export function Inner({ + verifyEmailControl, +}: { + verifyEmailControl: Dialog.DialogControlProps +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const agent = useAgent() + const control = Dialog.useDialogContext() + const {gtMobile} = useBreakpoints() + + const [currentStep, setCurrentStep] = useState< + 'StepOne' | 'StepTwo' | 'StepThree' + >('StepOne') + const [email, setEmail] = useState('') + const [confirmationCode, setConfirmationCode] = useState('') + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState('') + + const currentEmail = currentAccount?.email || '(no email)' + const uiStrings = { + StepOne: { + title: _(msg`Change Your Email`), + message: '', + }, + StepTwo: { + title: _(msg`Security Step Required`), + message: _( + msg`An email has been sent to your previous address, ${currentEmail}. It includes a confirmation code which you can enter below.`, + ), + }, + StepThree: { + title: _(msg`Email Updated!`), + message: _( + msg`Your email address has been updated but it is not yet verified. As a next step, please verify your new email.`, + ), + }, + } + + const onRequestChange = async () => { + if (email === currentAccount?.email) { + setError( + _( + msg`The email address you entered is the same as your current email address.`, + ), + ) + return + } + setError('') + setIsProcessing(true) + try { + const res = await agent.com.atproto.server.requestEmailUpdate() + if (res.data.tokenRequired) { + setCurrentStep('StepTwo') + } else { + await agent.com.atproto.server.updateEmail({email: email.trim()}) + await agent.resumeSession(agent.session!) + setCurrentStep('StepThree') + } + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onConfirm = async () => { + setError('') + setIsProcessing(true) + try { + await agent.com.atproto.server.updateEmail({ + email: email.trim(), + token: confirmationCode.trim(), + }) + await agent.resumeSession(agent.session!) + setCurrentStep('StepThree') + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onVerify = async () => { + control.close(() => { + verifyEmailControl.open() + }) + } + + return ( + + + + + + {uiStrings[currentStep].title} + + {error ? ( + + + + ) : null} + {currentStep === 'StepOne' ? ( + + + Enter your new email address below. + + + + + + ) : ( + + {uiStrings[currentStep].message} + + )} + + {currentStep === 'StepTwo' ? ( + + + Confirmation code + + + + + + ) : null} + + {currentStep === 'StepOne' ? ( + <> + + + + ) : currentStep === 'StepTwo' ? ( + <> + + + + ) : currentStep === 'StepThree' ? ( + <> + + + + ) : null} + + + + ) +} diff --git a/src/components/dialogs/VerifyEmailDialog.tsx b/src/components/dialogs/VerifyEmailDialog.tsx index ced9171ce..b8d1cd192 100644 --- a/src/components/dialogs/VerifyEmailDialog.tsx +++ b/src/components/dialogs/VerifyEmailDialog.tsx @@ -1,86 +1,115 @@ -import React from 'react' +import {useState} from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {useAgent, useSession} from '#/state/session' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' -import {atoms as a, useBreakpoints} from '#/alf' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' +import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {ChangeEmailDialog} from './ChangeEmailDialog' export function VerifyEmailDialog({ control, onCloseWithoutVerifying, onCloseAfterVerifying, reasonText, + changeEmailControl, + reminder, }: { control: Dialog.DialogControlProps onCloseWithoutVerifying?: () => void onCloseAfterVerifying?: () => void reasonText?: string + /** + * if a changeEmailControl for a ChangeEmailDialog is not provided, + * this component will create one for you. Using this prop + * helps reduce duplication, since these dialogs are often used together. + */ + changeEmailControl?: Dialog.DialogControlProps + reminder?: boolean }) { const agent = useAgent() + const fallbackChangeEmailControl = Dialog.useDialogControl() - const [didVerify, setDidVerify] = React.useState(false) + const [didVerify, setDidVerify] = useState(false) return ( - { - if (!didVerify) { - onCloseWithoutVerifying?.() - return - } - - try { - await agent.resumeSession(agent.session!) - onCloseAfterVerifying?.() - } catch (e: unknown) { - logger.error(String(e)) - return - } - }}> - - + - + onClose={async () => { + if (!didVerify) { + onCloseWithoutVerifying?.() + return + } + + try { + await agent.resumeSession(agent.session!) + onCloseAfterVerifying?.() + } catch (e: unknown) { + logger.error(String(e)) + return + } + }}> + + + + {!changeEmailControl && ( + + )} + ) } export function Inner({ - control, setDidVerify, reasonText, + changeEmailControl, + reminder, }: { - control: Dialog.DialogControlProps setDidVerify: (value: boolean) => void reasonText?: string + changeEmailControl: Dialog.DialogControlProps + reminder?: boolean }) { + const control = Dialog.useDialogContext() const {_} = useLingui() const {currentAccount} = useSession() const agent = useAgent() - const {openModal} = useModalControls() const {gtMobile} = useBreakpoints() + const t = useTheme() - const [currentStep, setCurrentStep] = React.useState< - 'StepOne' | 'StepTwo' | 'StepThree' - >('StepOne') - const [confirmationCode, setConfirmationCode] = React.useState('') - const [isProcessing, setIsProcessing] = React.useState(false) - const [error, setError] = React.useState('') + const [currentStep, setCurrentStep] = useState< + 'Reminder' | 'StepOne' | 'StepTwo' | 'StepThree' + >(reminder ? 'Reminder' : 'StepOne') + const [confirmationCode, setConfirmationCode] = useState('') + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState('') const uiStrings = { + Reminder: { + title: _(msg`Please Verify Your Email`), + message: _( + msg`Your email has not yet been verified. This is an important security step which we recommend.`, + ), + }, StepOne: { title: _(msg`Verify Your Email`), message: '', @@ -132,11 +161,20 @@ export function Inner({ return ( - + style={web({maxWidth: 450})}> + {currentStep === 'Reminder' && ( + + + + )} {uiStrings[currentStep].title} @@ -164,7 +202,7 @@ export function Inner({ onPress={e => { e.preventDefault() control.close(() => { - openModal({name: 'change-email'}) + changeEmailControl.open() }) return false }}> @@ -189,7 +227,7 @@ export function Inner({ onPress={e => { e.preventDefault() control.close(() => { - openModal({name: 'change-email'}) + changeEmailControl.open() }) return false }}> @@ -219,7 +257,32 @@ export function Inner({ ) : null} - {currentStep === 'StepOne' ? ( + {currentStep === 'Reminder' ? ( + <> + + + + ) : currentStep === 'StepOne' ? ( <> @@ -264,7 +327,7 @@ export function Inner({ ) : null} ) : currentStep === 'StepThree' ? (