diff --git a/src/components/dialogs/EmailDialog/components/TokenField.tsx b/src/components/dialogs/EmailDialog/components/TokenField.tsx new file mode 100644 index 0000000000..54d4f43599 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/TokenField.tsx @@ -0,0 +1,37 @@ +import {View, TextInputProps} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as TextField from '#/components/forms/TextField' +import {Shield_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' + +export function normalizeCode(value: string) { + const normalized = value.toUpperCase().replace(/[^A-Z2-7]/g, '') + if (normalized.length <= 5) return normalized + return `${normalized.slice(0, 5)}-${normalized.slice(5, 10)}` +} + +export function TokenField({ + value, + onChangeText, +}: Pick) { + const {_} = useLingui() + + const handleOnChangeText = (v: string) => { + onChangeText?.(normalizeCode(v)) + } + + return ( + + + + + + + ) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts new file mode 100644 index 0000000000..a442662fcf --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailUpdate() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + return (await agent.com.atproto.server.requestEmailUpdate()).data + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts new file mode 100644 index 0000000000..2ec1eb6dc2 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -0,0 +1,45 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' + +async function updateEmailAndRefreshSession( + agent: ReturnType, + email: string, + token?: string, +) { + await agent.com.atproto.server.updateEmail({email: email.trim(), token}) + await agent.resumeSession(agent.session!) +} + +export function useUpdateEmail() { + const agent = useAgent() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + + return useMutation< + {status: 'tokenRequired' | 'success'}, + Error, + {email: string; token?: string} + >({ + mutationFn: async ({email, token}: {email: string; token?: string}) => { + if (token) { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } else { + const {tokenRequired} = await requestEmailUpdate() + if (tokenRequired) { + return { + status: 'tokenRequired', + } + } else { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } + } + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx index bec06e7040..e8dc68d0a7 100644 --- a/src/components/dialogs/EmailDialog/index.tsx +++ b/src/components/dialogs/EmailDialog/index.tsx @@ -1,15 +1,23 @@ -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as Dialog from '#/components/Dialog' -import {Text} from '#/components/Typography' +import {ScreenID, Screen} from '#/components/dialogs/EmailDialog/types' + +/* + * Steps + */ +import {Update} from '#/components/dialogs/EmailDialog/screens/Update' export {useDialogControl} from '#/components/Dialog' +export {ScreenID, type Screen} from '#/components/dialogs/EmailDialog/types' export function EmailDialog({ control, + initialScreen, }: { control: Dialog.DialogControlProps + initialScreen: Screen }) { const {_} = useLingui() @@ -17,19 +25,28 @@ export function EmailDialog({ - - + + + ) } function Inner({ - control, + initialScreen, }: { control: Dialog.DialogControlProps + initialScreen: Screen }) { - return ( - Hello - ) + switch (initialScreen.id) { + case ScreenID.Update: { + return ( + + ) + } + default: { + return null + } + } } diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx new file mode 100644 index 0000000000..ee6f2fb791 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Update.tsx @@ -0,0 +1,213 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Text, Span} from '#/components/Typography' +import {atoms as a, useTheme} from '#/alf' +import {Divider} from '#/components/Divider' +import {Loader} from '#/components/Loader' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {logger} from '#/logger' +import {Admonition} from '#/components/Admonition' +import {useSession} from '#/state/session' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {InlineLinkText, createStaticClick} from '#/components/Link' +import {wait} from '#/lib/async/wait' + +import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {TokenField} from '#/components/dialogs/EmailDialog/components/TokenField' + +export function Update() { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const [email, setEmail] = useState('') + const [token, setToken] = useState('') + const [error, setError] = useState('') + const [tip, setTip] = useState('') + const [success, setSuccess] = useState(false) + const [tokenRequired, setTokenRequired] = useState(false) + + const [updateStatus, setUpdateStatus] = useState<'sending' | null>(null) + const {mutateAsync: updateEmail, isPending: isUpdateEmailPending} = + useUpdateEmail() + + const [resendStatus, setResendStatus] = useState< + 'sending' | 'success' | null + >(null) + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + + const handleResendRequestEmailUpdate = async () => { + setResendStatus('sending') + try { + await wait(1000, requestEmailUpdate()) + setResendStatus('success') + } finally { + setTimeout(() => { + setResendStatus(null) + }, 1000) + } + } + + const handleEmailChange = (email: string) => { + setEmail(email) + + // reset if email is edited + if (tokenRequired) { + setToken('') + setTokenRequired(false) + } + } + + const handleUpdateEmail = async () => { + setError('') + setTip('') + setUpdateStatus('sending') + + if (email === currentAccount!.email) { + setTip(_(msg`This email is already associated with your account.`)) + return + } + + try { + const {status} = await wait( + 1000, + updateEmail({ + email, + token, + }), + ) + + if (status === 'tokenRequired') { + setTokenRequired(true) + } else if (status === 'success') { + setSuccess(true) + } + } catch (e) { + logger.error('EmailDialog: update email failed', {safeMessage: e}) + setError(_(msg`Email updated failed, please try again.`)) + } finally { + setUpdateStatus(null) + } + } + + return ( + + + Update email + + + + + + Enter your new email address below. + + + + + + + + {tokenRequired && ( + <> + + + + Security step required + + + Check your email for a security code. + + + {!success && ( + + + Don't see an email?{' '} + { + handleResendRequestEmailUpdate() + })}> + Click here to resend. + + {' '} + + {resendStatus === 'sending' ? ( + + ) : resendStatus === 'success' ? ( + + ) : null} + + + )} + + + )} + + {error && {error}} + + {tip && {tip}} + + + {success ? ( + <> + + + + + + Success! + + + + + Click on the link in the email we just sent you to verify your + email. + + + + + ) : ( + + )} + + ) +} diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts index 9c5f6c573c..09166fbf6b 100644 --- a/src/components/dialogs/EmailDialog/types.ts +++ b/src/components/dialogs/EmailDialog/types.ts @@ -9,7 +9,7 @@ export type EmailDialogProps = { export type EmailDialogInnerProps = EmailDialogProps & {} export type Screen = { - id: ScreenID.ChangeEmail + id: ScreenID.Update } | { id: ScreenID.EnterCode /** @@ -29,7 +29,7 @@ export type Screen = { } export enum ScreenID { - ChangeEmail = 'ChangeEmail', // normal, instructs to click link first + Update = 'Update', // normal, instructs to click link first EnterCode = 'EnterCode', // if user elects to enter a code VerifyEmail = 'VerifyEmail', // as a separate step, instructs to click link first } diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index 7c50bd8dff..1f70a20b4c 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -24,6 +24,7 @@ import * as Layout from '#/components/Layout' import {ChangeHandleDialog} from './components/ChangeHandleDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' import {ExportCarDialog} from './components/ExportCarDialog' +import {EmailDialog, ScreenID} from '#/components/dialogs/EmailDialog' type Props = NativeStackScreenProps export function AccountSettingsScreen({}: Props) { @@ -167,10 +168,16 @@ export function AccountSettingsScreen({}: Props) { + + {/* + */}