about summary refs log tree commit diff
path: root/src/components/dialogs/EmailDialog/screens
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-05-07 10:23:33 -0500
committerGitHub <noreply@github.com>2025-05-07 10:23:33 -0500
commit0f96669f8c0d578d888c06496d97929130d34a1f (patch)
treecd053b6062fc5045eb14411135dc6ea46d5018f3 /src/components/dialogs/EmailDialog/screens
parent0edd3bd3b4445275ea3f9ddfc5f91ad4950acdd8 (diff)
downloadvoidsky-0f96669f8c0d578d888c06496d97929130d34a1f.tar.zst
[APP-1158] Refactor email-related dialogs (#8296)
* WIP

* Update email

* Fire off confirmation email after change

* Verify step, integrate stateful control

* Remove tentative EnterCode step

* Handle token step

* Handle instructions and integrate into 2FA setting

* Fix load state when reusing same email

* Add new state

* Add 2FA screens

* Clean up state in Update step

* Clean up verify state, handle normal callback

* Normalize convetions

* Add verification reminder screen

* Improve session refresh

* Handle verification requirements for composer and convo

* Fix lint

* Do better

* Couple missing translations

* Format

* Use listeners for easier to grok logic

* Clean errors

* Move to global context

* [APP-1158] Gate features by email verification state (#8305)

* Use new hook in all locations

* Format

* Seems to work, not great duplication

* Wrap all open composer calls

* Remove unneeded spans

* Missed one

* Fix handler on Conversation

* Gate new chat in header

* Add comment

* Remove whoopsie

* Format

* add hackfix for dialog not showing

* add prompt to accept chat btn

* navigation not necessary

* send back one screen, rather than home

* Update comment

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Clear dialog state

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Update icon

* Check color

* Add 2FA warning

* Update instructions

* Fix X button

* Use an effect silly goose

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/dialaUpdate copyogs/EmailDialog/screens/Update.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

* Update copy

* Update copy

* Update copy

* Update copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update copy

* Add link back to update email from verify email dialog

* Handle token field validation

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/components/dialogs/EmailDialog/screens')
-rw-r--r--src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx254
-rw-r--r--src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx137
-rw-r--r--src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx70
-rw-r--r--src/components/dialogs/EmailDialog/screens/Update.tsx319
-rw-r--r--src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx99
-rw-r--r--src/components/dialogs/EmailDialog/screens/Verify.tsx386
6 files changed, 1265 insertions, 0 deletions
diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx
new file mode 100644
index 000000000..1896ff27d
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx
@@ -0,0 +1,254 @@
+import {useReducer, useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
+import {
+  isValidCode,
+  TokenField,
+} from '#/components/dialogs/EmailDialog/components/TokenField'
+import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA'
+import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
+import {Divider} from '#/components/Divider'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {createStaticClick, InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Span, Text} from '#/components/Typography'
+
+type State = {
+  error: string
+  step: 'email' | 'token'
+  emailStatus: 'pending' | 'success' | 'error' | 'default'
+  tokenStatus: 'pending' | 'success' | 'error' | 'default'
+}
+
+type Action =
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setStep'
+      step: 'email' | 'token'
+    }
+  | {
+      type: 'setEmailStatus'
+      status: State['emailStatus']
+    }
+  | {
+      type: 'setTokenStatus'
+      status: State['tokenStatus']
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        emailStatus: 'error',
+        tokenStatus: 'error',
+      }
+    }
+    case 'setStep': {
+      return {
+        ...state,
+        error: '',
+        step: action.step,
+      }
+    }
+    case 'setEmailStatus': {
+      return {
+        ...state,
+        error: '',
+        emailStatus: action.status,
+      }
+    }
+    case 'setTokenStatus': {
+      return {
+        ...state,
+        error: '',
+        tokenStatus: action.status,
+      }
+    }
+    default: {
+      return state
+    }
+  }
+}
+
+export function Disable() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {currentAccount} = useSession()
+  const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
+  const {mutateAsync: manageEmail2FA} = useManageEmail2FA()
+  const control = useDialogContext()
+
+  const [token, setToken] = useState('')
+  const [state, dispatch] = useReducer(reducer, {
+    error: '',
+    step: 'email',
+    emailStatus: 'default',
+    tokenStatus: 'default',
+  })
+
+  const handleSendEmail = async () => {
+    dispatch({type: 'setEmailStatus', status: 'pending'})
+    try {
+      await wait(1000, requestEmailUpdate())
+      dispatch({type: 'setEmailStatus', status: 'success'})
+      setTimeout(() => {
+        dispatch({type: 'setStep', step: 'token'})
+      }, 1000)
+    } catch (e) {
+      logger.error('Manage2FA: email update code request failed', {
+        safeMessage: e,
+      })
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to send email, please try again.`),
+      })
+    }
+  }
+
+  const handleManageEmail2FA = async () => {
+    if (!isValidCode(token)) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid code.`),
+      })
+      return
+    }
+
+    dispatch({type: 'setTokenStatus', status: 'pending'})
+
+    try {
+      await wait(1000, manageEmail2FA({enabled: false, token}))
+      dispatch({type: 'setTokenStatus', status: 'success'})
+      setTimeout(() => {
+        control.close()
+      }, 1000)
+    } catch (e) {
+      logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e})
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to update email 2FA settings`),
+      })
+    }
+  }
+
+  return (
+    <View style={[a.gap_sm]}>
+      <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>
+        <Trans>Disable email 2FA</Trans>
+      </Text>
+
+      {state.step === 'email' ? (
+        <>
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              To disable your email 2FA method, please verify your access to{' '}
+              <Span style={[a.font_bold]}>{currentAccount?.email}</Span>
+            </Trans>
+          </Text>
+
+          <View style={[a.gap_lg, a.pt_sm]}>
+            {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+            <Button
+              label={_(msg`Send email`)}
+              size="large"
+              variant="solid"
+              color="primary"
+              onPress={handleSendEmail}
+              disabled={state.emailStatus === 'pending'}>
+              <ButtonText>
+                <Trans>Send email</Trans>
+              </ButtonText>
+              <ButtonIcon
+                icon={
+                  state.emailStatus === 'pending'
+                    ? Loader
+                    : state.emailStatus === 'success'
+                    ? Check
+                    : Envelope
+                }
+              />
+            </Button>
+
+            <Divider />
+
+            <Text
+              style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+              <Trans>
+                Have a code?{' '}
+                <InlineLinkText
+                  label={_(msg`Enter code`)}
+                  {...createStaticClick(() => {
+                    dispatch({type: 'setStep', step: 'token'})
+                  })}>
+                  Click here.
+                </InlineLinkText>
+              </Trans>
+            </Text>
+          </View>
+        </>
+      ) : (
+        <>
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              To disable your email 2FA method, please verify your access to{' '}
+              <Span style={[a.font_bold]}>{currentAccount?.email}</Span>
+            </Trans>
+          </Text>
+
+          <View style={[a.gap_sm, a.py_sm]}>
+            <TokenField
+              value={token}
+              onChangeText={setToken}
+              onSubmitEditing={handleManageEmail2FA}
+            />
+            <ResendEmailText onPress={handleSendEmail} />
+          </View>
+
+          {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+          <Button
+            label={_(msg`Disable 2FA`)}
+            size="large"
+            variant="solid"
+            color="primary"
+            onPress={handleManageEmail2FA}
+            disabled={
+              !token || token.length !== 11 || state.tokenStatus === 'pending'
+            }>
+            <ButtonText>
+              <Trans>Disable 2FA</Trans>
+            </ButtonText>
+            {state.tokenStatus === 'pending' ? (
+              <ButtonIcon icon={Loader} />
+            ) : state.tokenStatus === 'success' ? (
+              <ButtonIcon icon={Check} />
+            ) : null}
+          </Button>
+        </>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx
new file mode 100644
index 000000000..7a126792a
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx
@@ -0,0 +1,137 @@
+import {useReducer} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {useManageEmail2FA} from '#/components/dialogs/EmailDialog/data/useManageEmail2FA'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+type State = {
+  error: string
+  status: 'pending' | 'success' | 'error' | 'default'
+}
+
+type Action =
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setStatus'
+      status: State['status']
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        status: 'error',
+      }
+    }
+    case 'setStatus': {
+      return {
+        ...state,
+        error: '',
+        status: action.status,
+      }
+    }
+    default: {
+      return state
+    }
+  }
+}
+
+export function Enable() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {gtPhone} = useBreakpoints()
+  const {mutateAsync: manageEmail2FA} = useManageEmail2FA()
+  const control = useDialogContext()
+
+  const [state, dispatch] = useReducer(reducer, {
+    error: '',
+    status: 'default',
+  })
+
+  const handleManageEmail2FA = async () => {
+    dispatch({type: 'setStatus', status: 'pending'})
+
+    try {
+      await wait(1000, manageEmail2FA({enabled: true}))
+      dispatch({type: 'setStatus', status: 'success'})
+      setTimeout(() => {
+        control.close()
+      }, 1000)
+    } catch (e) {
+      logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e})
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to update email 2FA settings`),
+      })
+    }
+  }
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>
+          <Trans>Enable email 2FA</Trans>
+        </Text>
+
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>Require an email code to sign in to your account.</Trans>
+        </Text>
+      </View>
+
+      {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+      <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
+        <Button
+          label={_(msg`Enable`)}
+          size="large"
+          variant="solid"
+          color="primary"
+          onPress={handleManageEmail2FA}
+          disabled={state.status === 'pending'}>
+          <ButtonText>
+            <Trans>Enable</Trans>
+          </ButtonText>
+          <ButtonIcon
+            position="right"
+            icon={
+              state.status === 'pending'
+                ? Loader
+                : state.status === 'success'
+                ? Check
+                : ShieldIcon
+            }
+          />
+        </Button>
+        <Button
+          label={_(msg`Cancel`)}
+          size="large"
+          variant="solid"
+          color="secondary"
+          onPress={() => control.close()}>
+          <ButtonText>
+            <Trans>Cancel</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx
new file mode 100644
index 000000000..427a42b1f
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx
@@ -0,0 +1,70 @@
+import {useEffect, useState} from 'react'
+import {Trans} from '@lingui/macro'
+
+import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState'
+import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable'
+import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable'
+import {
+  ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+
+export function Manage2FA({showScreen}: ScreenProps<ScreenID.Manage2FA>) {
+  const {isEmailVerified, email2FAEnabled} = useAccountEmailState()
+  const [requestedAction, setRequestedAction] = useState<
+    'enable' | 'disable' | null
+  >(null)
+
+  useEffect(() => {
+    if (!isEmailVerified) {
+      showScreen({
+        id: ScreenID.Verify,
+        instructions: [
+          <Trans key="2fa">
+            You need to verify your email address before you can enable email
+            2FA.
+          </Trans>,
+        ],
+        onVerify: () => {
+          showScreen({
+            id: ScreenID.Manage2FA,
+          })
+        },
+      })
+    }
+  }, [isEmailVerified, showScreen])
+
+  /*
+   * Wacky state handling so that once 2FA settings change, we don't show the
+   * wrong step of this form - esb
+   */
+
+  if (email2FAEnabled) {
+    if (!requestedAction) {
+      setRequestedAction('disable')
+      return <Disable />
+    }
+
+    if (requestedAction === 'disable') {
+      return <Disable />
+    }
+    if (requestedAction === 'enable') {
+      return <Enable />
+    }
+  } else {
+    if (!requestedAction) {
+      setRequestedAction('enable')
+      return <Enable />
+    }
+
+    if (requestedAction === 'disable') {
+      return <Disable />
+    }
+    if (requestedAction === 'enable') {
+      return <Enable />
+    }
+  }
+
+  // should never happen
+  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 000000000..be0af8807
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Update.tsx
@@ -0,0 +1,319 @@
+import {useReducer} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {validate as validateEmail} from 'email-validator'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
+import {
+  isValidCode,
+  TokenField,
+} from '#/components/dialogs/EmailDialog/components/TokenField'
+import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
+import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
+import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail'
+import {
+  type ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+import {Divider} from '#/components/Divider'
+import * as TextField from '#/components/forms/TextField'
+import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+type State = {
+  step: 'email' | 'token'
+  mutationStatus: 'pending' | 'success' | 'error' | 'default'
+  error: string
+  emailValid: boolean
+  email: string
+  token: string
+}
+
+type Action =
+  | {
+      type: 'setStep'
+      step: State['step']
+    }
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setMutationStatus'
+      status: State['mutationStatus']
+    }
+  | {
+      type: 'setEmail'
+      value: string
+    }
+  | {
+      type: 'setToken'
+      value: string
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setStep': {
+      return {
+        ...state,
+        step: action.step,
+      }
+    }
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        mutationStatus: 'error',
+      }
+    }
+    case 'setMutationStatus': {
+      return {
+        ...state,
+        error: '',
+        mutationStatus: action.status,
+      }
+    }
+    case 'setEmail': {
+      const emailValid = validateEmail(action.value)
+      return {
+        ...state,
+        step: 'email',
+        token: '',
+        email: action.value,
+        emailValid,
+      }
+    }
+    case 'setToken': {
+      return {
+        ...state,
+        error: '',
+        token: action.value,
+      }
+    }
+  }
+}
+
+export function Update(_props: ScreenProps<ScreenID.Update>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {currentAccount} = useSession()
+  const [state, dispatch] = useReducer(reducer, {
+    step: 'email',
+    mutationStatus: 'default',
+    error: '',
+    email: '',
+    emailValid: true,
+    token: '',
+  })
+
+  const {mutateAsync: updateEmail} = useUpdateEmail()
+  const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
+  const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
+
+  const handleEmailChange = (email: string) => {
+    dispatch({
+      type: 'setEmail',
+      value: email,
+    })
+  }
+
+  const handleUpdateEmail = async () => {
+    if (state.step === 'token' && !isValidCode(state.token)) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid code.`),
+      })
+      return
+    }
+
+    dispatch({
+      type: 'setMutationStatus',
+      status: 'pending',
+    })
+
+    if (state.emailValid === false) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid email address.`),
+      })
+      return
+    }
+
+    if (state.email === currentAccount!.email) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`This email is already associated with your account.`),
+      })
+      return
+    }
+
+    try {
+      const {status} = await wait(
+        1000,
+        updateEmail({
+          email: state.email,
+          token: state.token,
+        }),
+      )
+
+      if (status === 'tokenRequired') {
+        dispatch({
+          type: 'setStep',
+          step: 'token',
+        })
+        dispatch({
+          type: 'setMutationStatus',
+          status: 'default',
+        })
+      } else if (status === 'success') {
+        dispatch({
+          type: 'setMutationStatus',
+          status: 'success',
+        })
+
+        try {
+          // fire off a confirmation email immediately
+          await requestEmailVerification()
+        } catch {}
+      }
+    } catch (e) {
+      logger.error('EmailDialog: update email failed', {safeMessage: e})
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to update email, please try again.`),
+      })
+    }
+  }
+
+  return (
+    <View style={[a.gap_lg]}>
+      <Text style={[a.text_xl, a.font_heavy]}>
+        <Trans>Update your email</Trans>
+      </Text>
+
+      {currentAccount?.emailAuthFactor && (
+        <Admonition type="warning">
+          <Trans>
+            If you update your email address, email 2FA will be disabled.
+          </Trans>
+        </Admonition>
+      )}
+
+      <View style={[a.gap_md]}>
+        <View>
+          <Text style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>Please enter your new email address.</Trans>
+          </Text>
+          <TextField.Root>
+            <TextField.Icon icon={Envelope} />
+            <TextField.Input
+              label={_(msg`New email address`)}
+              placeholder={_(msg`alice@example.com`)}
+              defaultValue={state.email}
+              onChangeText={
+                state.mutationStatus === 'success'
+                  ? undefined
+                  : handleEmailChange
+              }
+              keyboardType="email-address"
+              autoComplete="email"
+              autoCapitalize="none"
+              onSubmitEditing={handleUpdateEmail}
+            />
+          </TextField.Root>
+        </View>
+
+        {state.step === 'token' && (
+          <>
+            <Divider />
+            <View>
+              <Text style={[a.text_md, a.pb_sm, a.font_bold]}>
+                <Trans>Security step required</Trans>
+              </Text>
+              <Text
+                style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+                <Trans>
+                  Please enter the security code we sent to your previous email
+                  address.
+                </Trans>
+              </Text>
+              <TokenField
+                value={state.token}
+                onChangeText={
+                  state.mutationStatus === 'success'
+                    ? undefined
+                    : token => {
+                        dispatch({
+                          type: 'setToken',
+                          value: token,
+                        })
+                      }
+                }
+                onSubmitEditing={handleUpdateEmail}
+              />
+              {state.mutationStatus !== 'success' && (
+                <ResendEmailText
+                  onPress={requestEmailUpdate}
+                  style={[a.pt_sm]}
+                />
+              )}
+            </View>
+          </>
+        )}
+
+        {state.error && <Admonition type="error">{state.error}</Admonition>}
+      </View>
+
+      {state.mutationStatus === 'success' ? (
+        <>
+          <Divider />
+          <View style={[a.gap_sm]}>
+            <View style={[a.flex_row, a.gap_sm, a.align_center]}>
+              <Check fill={t.palette.positive_600} size="xs" />
+              <Text style={[a.text_md, a.font_heavy]}>
+                <Trans>Success!</Trans>
+              </Text>
+            </View>
+            <Text style={[a.leading_snug]}>
+              <Trans>
+                Please click on the link in the email we just sent you to verify
+                your new email address. This is an important step to allow you
+                to continue enjoying all the features of Bluesky.
+              </Trans>
+            </Text>
+          </View>
+        </>
+      ) : (
+        <Button
+          label={_(msg`Update email`)}
+          size="large"
+          variant="solid"
+          color="primary"
+          onPress={handleUpdateEmail}
+          disabled={
+            !state.email ||
+            (state.step === 'token' &&
+              (!state.token || state.token.length !== 11)) ||
+            state.mutationStatus === 'pending'
+          }>
+          <ButtonText>
+            <Trans>Update email</Trans>
+          </ButtonText>
+          {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />}
+        </Button>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
new file mode 100644
index 000000000..267b784b0
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx
@@ -0,0 +1,99 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {
+  ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+import {Divider} from '#/components/Divider'
+import {GradientFill} from '#/components/GradientFill'
+import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
+import {Text} from '#/components/Typography'
+
+export function VerificationReminder({
+  showScreen,
+}: ScreenProps<ScreenID.VerificationReminder>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtPhone, gtMobile} = useBreakpoints()
+  const control = useDialogContext()
+
+  const dialogPadding = gtMobile ? a.p_2xl.padding : a.p_xl.padding
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View
+        style={[
+          a.absolute,
+          {
+            top: platform({web: dialogPadding, default: a.p_2xl.padding}) * -1,
+            left: dialogPadding * -1,
+            right: dialogPadding * -1,
+            height: 150,
+          },
+        ]}>
+        <View
+          style={[
+            a.absolute,
+            a.inset_0,
+            a.align_center,
+            a.justify_center,
+            a.overflow_hidden,
+            a.pt_md,
+            t.atoms.bg_contrast_100,
+          ]}>
+          <GradientFill gradient={tokens.gradients.primary} />
+          <ShieldIcon width={64} fill="white" style={[a.z_10]} />
+        </View>
+      </View>
+
+      <View style={[a.mb_xs, {height: 150 - dialogPadding}]} />
+
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_xl, a.font_heavy]}>
+          <Trans>Please verify your email</Trans>
+        </Text>
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Your email has not yet been verified. Please verify your email in
+            order to enjoy all the features of Bluesky.
+          </Trans>
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}>
+        <Button
+          label={_(msg`Get started`)}
+          variant="solid"
+          color="primary"
+          size="large"
+          onPress={() =>
+            showScreen({
+              id: ScreenID.Verify,
+            })
+          }>
+          <ButtonText>
+            <Trans>Get started</Trans>
+          </ButtonText>
+        </Button>
+        <Button
+          label={_(msg`Maybe later`)}
+          accessibilityHint={_(msg`Snoozes the reminder`)}
+          variant="ghost"
+          color="secondary"
+          size="large"
+          onPress={() => control.close()}>
+          <ButtonText>
+            <Trans>Maybe later</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx
new file mode 100644
index 000000000..dabd0d2f2
--- /dev/null
+++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx
@@ -0,0 +1,386 @@
+import {useReducer} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
+import {
+  isValidCode,
+  TokenField,
+} from '#/components/dialogs/EmailDialog/components/TokenField'
+import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
+import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
+import {useOnEmailVerified} from '#/components/dialogs/EmailDialog/events'
+import {
+  ScreenID,
+  type ScreenProps,
+} from '#/components/dialogs/EmailDialog/types'
+import {Divider} from '#/components/Divider'
+import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {createStaticClick, InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Span, Text} from '#/components/Typography'
+
+type State = {
+  step: 'email' | 'token' | 'success'
+  mutationStatus: 'pending' | 'success' | 'error' | 'default'
+  error: string
+  token: string
+}
+
+type Action =
+  | {
+      type: 'setStep'
+      step: State['step']
+    }
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'setMutationStatus'
+      status: State['mutationStatus']
+    }
+  | {
+      type: 'setToken'
+      value: string
+    }
+
+function reducer(state: State, action: Action): State {
+  switch (action.type) {
+    case 'setStep': {
+      return {
+        ...state,
+        error: '',
+        mutationStatus: 'default',
+        step: action.step,
+      }
+    }
+    case 'setError': {
+      return {
+        ...state,
+        error: action.error,
+        mutationStatus: 'error',
+      }
+    }
+    case 'setMutationStatus': {
+      return {
+        ...state,
+        error: '',
+        mutationStatus: action.status,
+      }
+    }
+    case 'setToken': {
+      return {
+        ...state,
+        error: '',
+        token: action.value,
+      }
+    }
+  }
+}
+
+export function Verify({config, showScreen}: ScreenProps<ScreenID.Verify>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const cleanError = useCleanError()
+  const {currentAccount} = useSession()
+  const [state, dispatch] = useReducer(reducer, {
+    step: 'email',
+    mutationStatus: 'default',
+    error: '',
+    token: '',
+  })
+
+  const {mutateAsync: requestEmailVerification} = useRequestEmailVerification()
+  const {mutateAsync: confirmEmail} = useConfirmEmail()
+
+  useOnEmailVerified(() => {
+    if (config.onVerify) {
+      config.onVerify()
+    } else {
+      dispatch({
+        type: 'setStep',
+        step: 'success',
+      })
+    }
+  })
+
+  const handleRequestEmailVerification = async () => {
+    dispatch({
+      type: 'setMutationStatus',
+      status: 'pending',
+    })
+
+    try {
+      await wait(1000, requestEmailVerification())
+      dispatch({
+        type: 'setMutationStatus',
+        status: 'success',
+      })
+    } catch (e) {
+      logger.error('EmailDialog: sending verification email failed', {
+        safeMessage: e,
+      })
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to send email, please try again.`),
+      })
+    }
+  }
+
+  const handleConfirmEmail = async () => {
+    if (!isValidCode(state.token)) {
+      dispatch({
+        type: 'setError',
+        error: _(msg`Please enter a valid code.`),
+      })
+      return
+    }
+
+    dispatch({
+      type: 'setMutationStatus',
+      status: 'pending',
+    })
+
+    try {
+      await wait(1000, confirmEmail({token: state.token}))
+      dispatch({
+        type: 'setStep',
+        step: 'success',
+      })
+    } catch (e) {
+      logger.error('EmailDialog: confirming email failed', {
+        safeMessage: e,
+      })
+      const {clean} = cleanError(e)
+      dispatch({
+        type: 'setError',
+        error: clean || _(msg`Failed to verify email, please try again.`),
+      })
+    }
+  }
+
+  if (state.step === 'success') {
+    return (
+      <View style={[a.gap_lg]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.text_xl, a.font_heavy]}>
+            <Span style={{top: 1}}>
+              <Check size="sm" fill={t.palette.positive_600} />
+            </Span>
+            {'  '}
+            <Trans>Email verification complete!</Trans>
+          </Text>
+
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              You have successfully verified your email address. You can close
+              this dialog.
+            </Trans>
+          </Text>
+        </View>
+      </View>
+    )
+  }
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_xl, a.font_heavy]}>
+          {state.step === 'email' ? (
+            state.mutationStatus === 'success' ? (
+              <>
+                <Span style={{top: 1}}>
+                  <Check size="sm" fill={t.palette.positive_600} />
+                </Span>
+                {'  '}
+                <Trans>Email sent!</Trans>
+              </>
+            ) : (
+              <Trans>Verify your email</Trans>
+            )
+          ) : (
+            <Trans>Verify email code</Trans>
+          )}
+        </Text>
+
+        {state.step === 'email' && state.mutationStatus !== 'success' && (
+          <>
+            {config.instructions?.map((int, i) => (
+              <Text
+                key={i}
+                style={[
+                  a.italic,
+                  a.text_sm,
+                  a.leading_snug,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                {int}
+              </Text>
+            ))}
+          </>
+        )}
+
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          {state.step === 'email' ? (
+            state.mutationStatus === 'success' ? (
+              <Trans>
+                We sent an email to{' '}
+                <Span style={[a.font_bold, t.atoms.text]}>
+                  {currentAccount!.email}
+                </Span>{' '}
+                containing a link. Please click on it to complete the email
+                verification process.
+              </Trans>
+            ) : (
+              <Trans>
+                We'll send an email to{' '}
+                <Span style={[a.font_bold, t.atoms.text]}>
+                  {currentAccount!.email}
+                </Span>{' '}
+                containing a link. Please click on it to complete the email
+                verification process.
+              </Trans>
+            )
+          ) : (
+            <Trans>
+              Please enter the code we sent to{' '}
+              <Span style={[a.font_bold, t.atoms.text]}>
+                {currentAccount!.email}
+              </Span>{' '}
+              below.
+            </Trans>
+          )}
+        </Text>
+
+        {state.step === 'email' && state.mutationStatus !== 'success' && (
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              If you need to update your email,{' '}
+              <InlineLinkText
+                label={_(msg`Click here to update your email`)}
+                {...createStaticClick(() => {
+                  showScreen({id: ScreenID.Update})
+                })}>
+                click here
+              </InlineLinkText>
+              .
+            </Trans>
+          </Text>
+        )}
+
+        {state.step === 'email' && state.mutationStatus === 'success' && (
+          <ResendEmailText onPress={requestEmailVerification} />
+        )}
+      </View>
+
+      {state.step === 'email' && state.mutationStatus !== 'success' ? (
+        <>
+          {state.error && <Admonition type="error">{state.error}</Admonition>}
+          <Button
+            label={_(msg`Send verification email`)}
+            size="large"
+            variant="solid"
+            color="primary"
+            onPress={handleRequestEmailVerification}
+            disabled={state.mutationStatus === 'pending'}>
+            <ButtonText>
+              <Trans>Send email</Trans>
+            </ButtonText>
+            <ButtonIcon
+              icon={state.mutationStatus === 'pending' ? Loader : Envelope}
+            />
+          </Button>
+        </>
+      ) : null}
+
+      {state.step === 'email' && (
+        <>
+          <Divider />
+
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Have a code?{' '}
+              <InlineLinkText
+                label={_(msg`Enter code`)}
+                {...createStaticClick(() => {
+                  dispatch({
+                    type: 'setStep',
+                    step: 'token',
+                  })
+                })}>
+                Click here.
+              </InlineLinkText>
+            </Trans>
+          </Text>
+        </>
+      )}
+
+      {state.step === 'token' ? (
+        <>
+          <TokenField
+            value={state.token}
+            onChangeText={token => {
+              dispatch({
+                type: 'setToken',
+                value: token,
+              })
+            }}
+            onSubmitEditing={handleConfirmEmail}
+          />
+
+          {state.error && <Admonition type="error">{state.error}</Admonition>}
+
+          <Button
+            label={_(msg`Verify code`)}
+            size="large"
+            variant="solid"
+            color="primary"
+            onPress={handleConfirmEmail}
+            disabled={
+              !state.token ||
+              state.token.length !== 11 ||
+              state.mutationStatus === 'pending'
+            }>
+            <ButtonText>
+              <Trans>Verify code</Trans>
+            </ButtonText>
+            {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />}
+          </Button>
+
+          <Divider />
+
+          <Text
+            style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Don't have a code or need a new one?{' '}
+              <InlineLinkText
+                label={_(msg`Click here to restart the verification process.`)}
+                {...createStaticClick(() => {
+                  dispatch({
+                    type: 'setStep',
+                    step: 'email',
+                  })
+                })}>
+                Click here.
+              </InlineLinkText>
+            </Trans>
+          </Text>
+        </>
+      ) : null}
+    </View>
+  )
+}