Update email

This commit is contained in:
Eric Bailey
2025-04-25 13:01:40 -05:00
parent 71acb413e9
commit d7d297b03b
7 changed files with 342 additions and 10 deletions
@@ -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<TextInputProps, 'value' | 'onChangeText' | 'onSubmitEditing'>) {
const {_} = useLingui()
const handleOnChangeText = (v: string) => {
onChangeText?.(normalizeCode(v))
}
return (
<View>
<TextField.Root>
<TextField.Icon icon={Shield} />
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
value={value}
onChangeText={handleOnChangeText}
/>
</TextField.Root>
</View>
)
}
@@ -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
},
})
}
@@ -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<typeof useAgent>,
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',
}
}
}
},
})
}
+25 -8
View File
@@ -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({
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Make adjustments to your account email settings`)}>
<Inner control={control} />
<Dialog.ScrollableInner label={_(msg`Make adjustments to your account email settings`)} style={[{maxWidth: 400}]}>
<Inner control={control} initialScreen={initialScreen} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({
control,
initialScreen,
}: {
control: Dialog.DialogControlProps
initialScreen: Screen
}) {
return (
<Text>Hello</Text>
)
switch (initialScreen.id) {
case ScreenID.Update: {
return (
<Update />
)
}
default: {
return null
}
}
}
@@ -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 (
<View style={[a.gap_lg]}>
<Text style={[a.text_xl, a.font_heavy]}>
<Trans>Update email</Trans>
</Text>
<View style={[a.gap_md]}>
<View>
<Text style={[a.pb_sm, t.atoms.text_contrast_medium]}>
<Trans>Enter your new email address below.</Trans>
</Text>
<TextField.Root>
<TextField.Icon icon={At} />
<TextField.Input
label={_(msg`New email address`)}
placeholder={_(msg`alice@example.com`)}
defaultValue={email}
onChangeText={success ? undefined : handleEmailChange}
keyboardType="email-address"
autoComplete="email"
onSubmitEditing={handleUpdateEmail}
/>
</TextField.Root>
</View>
{tokenRequired && (
<>
<Divider />
<View>
<Text style={[a.text_md, a.pb_sm, a.font_bold]}>
<Trans>Security step required</Trans>
</Text>
<Text style={[a.pb_sm, t.atoms.text_contrast_medium]}>
<Trans>Check your email for a security code.</Trans>
</Text>
<TokenField
value={token}
onChangeText={success ? undefined : setToken}
onSubmitEditing={handleUpdateEmail}
/>
{!success && (
<Text
style={[
a.italic,
a.pt_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Don't see an email?{' '}
<InlineLinkText
label={_(msg`Resend`)}
{...createStaticClick(() => {
handleResendRequestEmailUpdate()
})}>
Click here to resend.
</InlineLinkText>
</Trans>{' '}
<Span style={{top: 1}}>
{resendStatus === 'sending' ? (
<Loader size="xs" />
) : resendStatus === 'success' ? (
<Check size="xs" fill={t.palette.positive_500} />
) : null}
</Span>
</Text>
)}
</View>
</>
)}
{error && <Admonition type="error">{error}</Admonition>}
{tip && <Admonition type="tip">{tip}</Admonition>}
</View>
{success ? (
<>
<Divider />
<View style={[a.gap_sm]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<Check fill={t.palette.positive_500} size="xs" />
<Text style={[a.text_md, a.font_heavy]}>
<Trans>Success!</Trans>
</Text>
</View>
<Text style={[a.leading_snug]}>
<Trans>
Click on the link in the email we just sent you to verify your
email.
</Trans>
</Text>
</View>
</>
) : (
<Button
label={_(msg`Update email`)}
size="large"
variant="solid"
color={success ? 'secondary' : 'primary'}
onPress={handleUpdateEmail}
disabled={
!email ||
(tokenRequired && !token) ||
isUpdateEmailPending ||
success
}>
<ButtonText>
<Trans>Update email</Trans>
</ButtonText>
{updateStatus === 'sending' && <ButtonIcon icon={Loader} />}
</Button>
)}
</View>
)
}
+2 -2
View File
@@ -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
}
+7
View File
@@ -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<CommonNavigatorParams, 'AccountSettings'>
export function AccountSettingsScreen({}: Props) {
@@ -167,10 +168,16 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.Container>
</Layout.Content>
<EmailDialog
control={changeEmailControl}
initialScreen={{id: ScreenID.Update}}
/>
{/*
<ChangeEmailDialog
control={changeEmailControl}
verifyEmailControl={verifyEmailControl}
/>
*/}
<VerifyEmailDialog
control={verifyEmailControl}
changeEmailControl={changeEmailControl}