about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-09-28 12:08:00 -0700
committerGitHub <noreply@github.com>2023-09-28 12:08:00 -0700
commitcd3b0e54fbefa6c38ae6ad81198c8d766baee2c5 (patch)
tree11676f9031d2c5f27e298feec178ce7c2df62262
parent16763d1d4118292432678ef256226139c0be73c1 (diff)
downloadvoidsky-cd3b0e54fbefa6c38ae6ad81198c8d766baee2c5.tar.zst
Email verification and change flows (#1560)
* fix 'Reposted by' text overflow

* Add email verification flow

* Implement change email flow

* Add verify email reminder on load

* Bump @atproto/api@0.6.20

* Trim the inputs

* Accessibility fixes

* Fix typo

* Fix: include the day in the sharding check

* Update auto behaviors

* Update yarn.lock

* Temporary error message

---------

Co-authored-by: Eric Bailey <git@esb.lol>
-rw-r--r--package.json2
-rw-r--r--src/lib/strings/helpers.ts17
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/state/models/session.ts16
-rw-r--r--src/state/models/ui/reminders.ts65
-rw-r--r--src/state/models/ui/shell.ts22
-rw-r--r--src/view/com/modals/ChangeEmail.tsx280
-rw-r--r--src/view/com/modals/Confirm.tsx3
-rw-r--r--src/view/com/modals/Modal.tsx8
-rw-r--r--src/view/com/modals/Modal.web.tsx6
-rw-r--r--src/view/com/modals/VerifyEmail.tsx296
-rw-r--r--src/view/com/util/forms/Button.tsx14
-rw-r--r--src/view/screens/Settings.tsx83
-rw-r--r--yarn.lock54
14 files changed, 856 insertions, 16 deletions
diff --git a/package.json b/package.json
index 32ca3de7a..28e9a6992 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
     "build:apk": "eas build -p android --profile dev-android-apk"
   },
   "dependencies": {
-    "@atproto/api": "^0.6.16",
+    "@atproto/api": "^0.6.20",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@emoji-mart/react": "^1.1.1",
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index 183d53e31..ef93a366f 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -15,3 +15,20 @@ export function enforceLen(str: string, len: number, ellipsis = false): string {
   }
   return str
 }
+
+// https://stackoverflow.com/a/52171480
+export function toHashCode(str: string, seed = 0): number {
+  let h1 = 0xdeadbeef ^ seed,
+    h2 = 0x41c6ce57 ^ seed
+  for (let i = 0, ch; i < str.length; i++) {
+    ch = str.charCodeAt(i)
+    h1 = Math.imul(h1 ^ ch, 2654435761)
+    h2 = Math.imul(h2 ^ ch, 1597334677)
+  }
+  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
+  h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
+  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
+  h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
+
+  return 4294967296 * (2097151 & h2) + (h1 >>> 0)
+}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 1a81072a2..363a81c0f 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -21,6 +21,7 @@ import {PreferencesModel} from './ui/preferences'
 import {resetToTab} from '../../Navigation'
 import {ImageSizesCache} from './cache/image-sizes'
 import {MutedThreads} from './muted-threads'
+import {Reminders} from './ui/reminders'
 import {reset as resetNavigation} from '../../Navigation'
 
 // TEMPORARY (APP-700)
@@ -53,6 +54,7 @@ export class RootStoreModel {
   linkMetas = new LinkMetasCache(this)
   imageSizes = new ImageSizesCache()
   mutedThreads = new MutedThreads()
+  reminders = new Reminders(this)
 
   constructor(agent: BskyAgent) {
     this.agent = agent
@@ -77,6 +79,7 @@ export class RootStoreModel {
       preferences: this.preferences.serialize(),
       invitedUsers: this.invitedUsers.serialize(),
       mutedThreads: this.mutedThreads.serialize(),
+      reminders: this.reminders.serialize(),
     }
   }
 
@@ -109,6 +112,9 @@ export class RootStoreModel {
       if (hasProp(v, 'mutedThreads')) {
         this.mutedThreads.hydrate(v.mutedThreads)
       }
+      if (hasProp(v, 'reminders')) {
+        this.reminders.hydrate(v.reminders)
+      }
     }
   }
 
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 1bc722c8c..7cd3c1222 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -30,6 +30,7 @@ export const accountData = z.object({
   email: z.string().optional(),
   displayName: z.string().optional(),
   aviUrl: z.string().optional(),
+  emailConfirmed: z.boolean().optional(),
 })
 export type AccountData = z.infer<typeof accountData>
 
@@ -106,6 +107,10 @@ export class SessionModel {
     return this.accounts.filter(acct => acct.did !== this.data?.did)
   }
 
+  get emailNeedsConfirmation() {
+    return !this.currentSession?.emailConfirmed
+  }
+
   get isSandbox() {
     if (!this.data) {
       return false
@@ -217,6 +222,7 @@ export class SessionModel {
         ? addedInfo.displayName
         : existingAccount?.displayName || '',
       aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
+      emailConfirmed: session?.emailConfirmed,
     }
     if (!existingAccount) {
       this.accounts.push(newAccount)
@@ -246,6 +252,8 @@ export class SessionModel {
       did: acct.did,
       displayName: acct.displayName,
       aviUrl: acct.aviUrl,
+      email: acct.email,
+      emailConfirmed: acct.emailConfirmed,
     }))
   }
 
@@ -297,6 +305,8 @@ export class SessionModel {
           refreshJwt: account.refreshJwt || '',
           did: account.did,
           handle: account.handle,
+          email: account.email,
+          emailConfirmed: account.emailConfirmed,
         }),
       )
       const addedInfo = await this.loadAccountInfo(agent, account.did)
@@ -452,4 +462,10 @@ export class SessionModel {
       await this.rootStore.me.load()
     }
   }
+
+  updateLocalAccountData(changes: Partial<AccountData>) {
+    this.accounts = this.accounts.map(acct =>
+      acct.did === this.data?.did ? {...acct, ...changes} : acct,
+    )
+  }
 }
diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts
new file mode 100644
index 000000000..f8becdec3
--- /dev/null
+++ b/src/state/models/ui/reminders.ts
@@ -0,0 +1,65 @@
+import {makeAutoObservable} from 'mobx'
+import {isObj, hasProp} from 'lib/type-guards'
+import {RootStoreModel} from '../root-store'
+import {toHashCode} from 'lib/strings/helpers'
+
+const DAY = 60e3 * 24 * 1 // 1 day (ms)
+
+export class Reminders {
+  // NOTE
+  // by defaulting to the current date, we ensure that the user won't be nagged
+  // on first run (aka right after creating an account)
+  // -prf
+  lastEmailConfirm: Date = new Date()
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {serialize: false, hydrate: false},
+      {autoBind: true},
+    )
+  }
+
+  serialize() {
+    return {
+      lastEmailConfirm: this.lastEmailConfirm
+        ? this.lastEmailConfirm.toISOString()
+        : undefined,
+    }
+  }
+
+  hydrate(v: unknown) {
+    if (
+      isObj(v) &&
+      hasProp(v, 'lastEmailConfirm') &&
+      typeof v.lastEmailConfirm === 'string'
+    ) {
+      this.lastEmailConfirm = new Date(v.lastEmailConfirm)
+    }
+  }
+
+  get shouldRequestEmailConfirmation() {
+    const sess = this.rootStore.session.currentSession
+    if (!sess) {
+      return false
+    }
+    if (sess.emailConfirmed) {
+      return false
+    }
+    const today = new Date()
+    // shard the users into 2 day of the week buckets
+    // (this is to avoid a sudden influx of email updates when
+    // this feature rolls out)
+    const code = toHashCode(sess.did) % 7
+    if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
+      return false
+    }
+    // only ask once a day at most, but because of the bucketing
+    // this will be more like weekly
+    return Number(today) - Number(this.lastEmailConfirm) > DAY
+  }
+
+  setEmailConfirmationRequested() {
+    this.lastEmailConfirm = new Date()
+  }
+}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 647513563..15d92f927 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -24,6 +24,7 @@ export interface ConfirmModal {
   onPressCancel?: () => void | Promise<void>
   confirmBtnText?: string
   confirmBtnStyle?: StyleProp<ViewStyle>
+  cancelBtnText?: string
 }
 
 export interface EditProfileModal {
@@ -140,6 +141,15 @@ export interface BirthDateSettingsModal {
   name: 'birth-date-settings'
 }
 
+export interface VerifyEmailModal {
+  name: 'verify-email'
+  showReminder?: boolean
+}
+
+export interface ChangeEmailModal {
+  name: 'change-email'
+}
+
 export type Modal =
   // Account
   | AddAppPasswordModal
@@ -148,6 +158,8 @@ export type Modal =
   | EditProfileModal
   | ProfilePreviewModal
   | BirthDateSettingsModal
+  | VerifyEmailModal
+  | ChangeEmailModal
 
   // Curation
   | ContentFilteringSettingsModal
@@ -250,6 +262,7 @@ export class ShellUiModel {
     })
 
     this.setupClock()
+    this.setupLoginModals()
   }
 
   serialize(): unknown {
@@ -375,4 +388,13 @@ export class ShellUiModel {
       })
     }, 60_000)
   }
+
+  setupLoginModals() {
+    this.rootStore.onSessionReady(() => {
+      if (this.rootStore.reminders.shouldRequestEmailConfirmation) {
+        this.openModal({name: 'verify-email', showReminder: true})
+        this.rootStore.reminders.setEmailConfirmationRequested()
+      }
+    })
+  }
 }
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
new file mode 100644
index 000000000..c92dabdca
--- /dev/null
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -0,0 +1,280 @@
+import React, {useState} from 'react'
+import {
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  SafeAreaView,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {ScrollView, TextInput} from './util'
+import {observer} from 'mobx-react-lite'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import * as Toast from '../util/Toast'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {cleanError} from 'lib/strings/errors'
+
+enum Stages {
+  InputEmail,
+  ConfirmCode,
+  Done,
+}
+
+export const snapPoints = ['90%']
+
+export const Component = observer(function Component({}: {}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [stage, setStage] = useState<Stages>(Stages.InputEmail)
+  const [email, setEmail] = useState<string>(
+    store.session.currentSession?.email || '',
+  )
+  const [confirmationCode, setConfirmationCode] = useState<string>('')
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [error, setError] = useState<string>('')
+  const {isMobile} = useWebMediaQueries()
+
+  const onRequestChange = async () => {
+    if (email === store.session.currentSession?.email) {
+      setError('Enter your new email above')
+      return
+    }
+    setError('')
+    setIsProcessing(true)
+    try {
+      const res = await store.agent.com.atproto.server.requestEmailUpdate()
+      if (res.data.tokenRequired) {
+        setStage(Stages.ConfirmCode)
+      } else {
+        await store.agent.com.atproto.server.updateEmail({email: email.trim()})
+        store.session.updateLocalAccountData({
+          email: email.trim(),
+          emailConfirmed: false,
+        })
+        Toast.show('Email updated')
+        setStage(Stages.Done)
+      }
+    } catch (e) {
+      let err = cleanError(String(e))
+      // TEMP
+      // while rollout is occuring, we're giving a temporary error message
+      // you can remove this any time after Oct2023
+      // -prf
+      if (err === 'email must be confirmed (temporary)') {
+        err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`
+      }
+      setError(err)
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onConfirm = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.agent.com.atproto.server.updateEmail({
+        email: email.trim(),
+        token: confirmationCode.trim(),
+      })
+      store.session.updateLocalAccountData({
+        email: email.trim(),
+        emailConfirmed: false,
+      })
+      Toast.show('Email updated')
+      setStage(Stages.Done)
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onVerify = async () => {
+    store.shell.closeModal()
+    store.shell.openModal({name: 'verify-email'})
+  }
+
+  return (
+    <KeyboardAvoidingView
+      behavior="padding"
+      style={[pal.view, styles.container]}>
+      <SafeAreaView style={s.flex1}>
+        <ScrollView
+          testID="changeEmailModal"
+          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+          <View style={styles.titleSection}>
+            <Text type="title-lg" style={[pal.text, styles.title]}>
+              {stage === Stages.InputEmail ? 'Change Your Email' : ''}
+              {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
+              {stage === Stages.Done ? 'Email Updated' : ''}
+            </Text>
+          </View>
+
+          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+            {stage === Stages.InputEmail ? (
+              <>Enter your new email address below.</>
+            ) : stage === Stages.ConfirmCode ? (
+              <>
+                An email has been sent to your previous address,{' '}
+                {store.session.currentSession?.email || ''}. It includes a
+                confirmation code which you can enter below.
+              </>
+            ) : (
+              <>
+                Your email has been updated but not verified. As a next step,
+                please verify your new email.
+              </>
+            )}
+          </Text>
+
+          {stage === Stages.InputEmail && (
+            <TextInput
+              testID="emailInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="alice@mail.com"
+              placeholderTextColor={pal.colors.textLight}
+              value={email}
+              onChangeText={setEmail}
+              accessible={true}
+              accessibilityLabel="Email"
+              accessibilityHint=""
+              autoCapitalize="none"
+              autoComplete="email"
+              autoCorrect={false}
+            />
+          )}
+          {stage === Stages.ConfirmCode && (
+            <TextInput
+              testID="confirmCodeInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="XXXXX-XXXXX"
+              placeholderTextColor={pal.colors.textLight}
+              value={confirmationCode}
+              onChangeText={setConfirmationCode}
+              accessible={true}
+              accessibilityLabel="Confirmation code"
+              accessibilityHint=""
+              autoCapitalize="none"
+              autoComplete="off"
+              autoCorrect={false}
+            />
+          )}
+
+          {error ? (
+            <ErrorMessage message={error} style={styles.error} />
+          ) : undefined}
+
+          <View style={[styles.btnContainer]}>
+            {isProcessing ? (
+              <View style={styles.btn}>
+                <ActivityIndicator color="#fff" />
+              </View>
+            ) : (
+              <View style={{gap: 6}}>
+                {stage === Stages.InputEmail && (
+                  <Button
+                    testID="requestChangeBtn"
+                    type="primary"
+                    onPress={onRequestChange}
+                    accessibilityLabel="Request Change"
+                    accessibilityHint=""
+                    label="Request Change"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                {stage === Stages.ConfirmCode && (
+                  <Button
+                    testID="confirmBtn"
+                    type="primary"
+                    onPress={onConfirm}
+                    accessibilityLabel="Confirm Change"
+                    accessibilityHint=""
+                    label="Confirm Change"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                {stage === Stages.Done && (
+                  <Button
+                    testID="verifyBtn"
+                    type="primary"
+                    onPress={onVerify}
+                    accessibilityLabel="Verify New Email"
+                    accessibilityHint=""
+                    label="Verify New Email"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                <Button
+                  testID="cancelBtn"
+                  type="default"
+                  onPress={() => store.shell.closeModal()}
+                  accessibilityLabel="Cancel"
+                  accessibilityHint=""
+                  label="Cancel"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              </View>
+            )}
+          </View>
+        </ScrollView>
+      </SafeAreaView>
+    </KeyboardAvoidingView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isWeb ? 0 : 4,
+    paddingBottom: isWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  error: {
+    borderRadius: 6,
+    marginTop: 10,
+  },
+  emailContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 12,
+  },
+  textInput: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+    fontSize: 16,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+})
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 270177182..c1324b1cb 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -23,6 +23,7 @@ export function Component({
   onPressCancel,
   confirmBtnText,
   confirmBtnStyle,
+  cancelBtnText,
 }: ConfirmModal) {
   const pal = usePalette('default')
   const store = useStores()
@@ -84,7 +85,7 @@ export function Component({
           accessibilityLabel="Cancel"
           accessibilityHint="">
           <Text type="button-lg" style={pal.textLight}>
-            Cancel
+            {cancelBtnText ?? 'Cancel'}
           </Text>
         </TouchableOpacity>
       )}
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index d79c77db3..8590a2698 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -30,6 +30,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as ModerationDetailsModal from './ModerationDetails'
 import * as BirthDateSettingsModal from './BirthDateSettings'
+import * as VerifyEmailModal from './VerifyEmail'
+import * as ChangeEmailModal from './ChangeEmail'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 
@@ -136,6 +138,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'birth-date-settings') {
     snapPoints = BirthDateSettingsModal.snapPoints
     element = <BirthDateSettingsModal.Component />
+  } else if (activeModal?.name === 'verify-email') {
+    snapPoints = VerifyEmailModal.snapPoints
+    element = <VerifyEmailModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'change-email') {
+    snapPoints = ChangeEmailModal.snapPoints
+    element = <ChangeEmailModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 3e87e0e3c..7548fb806 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -28,6 +28,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as ModerationDetailsModal from './ModerationDetails'
 import * as BirthDateSettingsModal from './BirthDateSettings'
+import * as VerifyEmailModal from './VerifyEmail'
+import * as ChangeEmailModal from './ChangeEmail'
 
 export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
@@ -110,6 +112,10 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ModerationDetailsModal.Component {...modal} />
   } else if (modal.name === 'birth-date-settings') {
     element = <BirthDateSettingsModal.Component />
+  } else if (modal.name === 'verify-email') {
+    element = <VerifyEmailModal.Component {...modal} />
+  } else if (modal.name === 'change-email') {
+    element = <ChangeEmailModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
new file mode 100644
index 000000000..1b4ddcda4
--- /dev/null
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -0,0 +1,296 @@
+import React, {useState} from 'react'
+import {
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  Pressable,
+  SafeAreaView,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {ScrollView, TextInput} from './util'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import * as Toast from '../util/Toast'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {cleanError} from 'lib/strings/errors'
+
+export const snapPoints = ['90%']
+
+enum Stages {
+  Reminder,
+  Email,
+  ConfirmCode,
+}
+
+export const Component = observer(function Component({
+  showReminder,
+}: {
+  showReminder?: boolean
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [stage, setStage] = useState<Stages>(
+    showReminder ? Stages.Reminder : Stages.Email,
+  )
+  const [confirmationCode, setConfirmationCode] = useState<string>('')
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [error, setError] = useState<string>('')
+  const {isMobile} = useWebMediaQueries()
+
+  const onSendEmail = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.agent.com.atproto.server.requestEmailConfirmation()
+      setStage(Stages.ConfirmCode)
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onConfirm = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.agent.com.atproto.server.confirmEmail({
+        email: (store.session.currentSession?.email || '').trim(),
+        token: confirmationCode.trim(),
+      })
+      store.session.updateLocalAccountData({emailConfirmed: true})
+      Toast.show('Email verified')
+      store.shell.closeModal()
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onEmailIncorrect = () => {
+    store.shell.closeModal()
+    store.shell.openModal({name: 'change-email'})
+  }
+
+  return (
+    <KeyboardAvoidingView
+      behavior="padding"
+      style={[pal.view, styles.container]}>
+      <SafeAreaView style={s.flex1}>
+        <ScrollView
+          testID="verifyEmailModal"
+          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+          <View style={styles.titleSection}>
+            <Text type="title-lg" style={[pal.text, styles.title]}>
+              {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
+              {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
+              {stage === Stages.Email ? 'Verify Your Email' : ''}
+            </Text>
+          </View>
+
+          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+            {stage === Stages.Reminder ? (
+              <>
+                Your email has not yet been verified. This is an important
+                security step which we recommend.
+              </>
+            ) : stage === Stages.Email ? (
+              <>
+                This is important in case you ever need to change your email or
+                reset your password.
+              </>
+            ) : stage === Stages.ConfirmCode ? (
+              <>
+                An email has been sent to{' '}
+                {store.session.currentSession?.email || ''}. It includes a
+                confirmation code which you can enter below.
+              </>
+            ) : (
+              ''
+            )}
+          </Text>
+
+          {stage === Stages.Email ? (
+            <>
+              <View style={styles.emailContainer}>
+                <FontAwesomeIcon
+                  icon="envelope"
+                  color={pal.colors.text}
+                  size={16}
+                />
+                <Text
+                  type="xl-medium"
+                  style={[pal.text, s.flex1, {minWidth: 0}]}>
+                  {store.session.currentSession?.email || ''}
+                </Text>
+              </View>
+              <Pressable
+                accessibilityRole="link"
+                accessibilityLabel="Change my email"
+                accessibilityHint=""
+                onPress={onEmailIncorrect}
+                style={styles.changeEmailLink}>
+                <Text type="lg" style={pal.link}>
+                  Change
+                </Text>
+              </Pressable>
+            </>
+          ) : stage === Stages.ConfirmCode ? (
+            <TextInput
+              testID="confirmCodeInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="XXXXX-XXXXX"
+              placeholderTextColor={pal.colors.textLight}
+              value={confirmationCode}
+              onChangeText={setConfirmationCode}
+              accessible={true}
+              accessibilityLabel="Confirmation code"
+              accessibilityHint=""
+              autoCapitalize="none"
+              autoComplete="off"
+              autoCorrect={false}
+            />
+          ) : undefined}
+
+          {error ? (
+            <ErrorMessage message={error} style={styles.error} />
+          ) : undefined}
+
+          <View style={[styles.btnContainer]}>
+            {isProcessing ? (
+              <View style={styles.btn}>
+                <ActivityIndicator color="#fff" />
+              </View>
+            ) : (
+              <View style={{gap: 6}}>
+                {stage === Stages.Reminder && (
+                  <Button
+                    testID="getStartedBtn"
+                    type="primary"
+                    onPress={() => setStage(Stages.Email)}
+                    accessibilityLabel="Get Started"
+                    accessibilityHint=""
+                    label="Get Started"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                {stage === Stages.Email && (
+                  <>
+                    <Button
+                      testID="sendEmailBtn"
+                      type="primary"
+                      onPress={onSendEmail}
+                      accessibilityLabel="Send Confirmation Email"
+                      accessibilityHint=""
+                      label="Send Confirmation Email"
+                      labelContainerStyle={{
+                        justifyContent: 'center',
+                        padding: 4,
+                      }}
+                      labelStyle={[s.f18]}
+                    />
+                    <Button
+                      testID="haveCodeBtn"
+                      type="default"
+                      accessibilityLabel="I have a code"
+                      accessibilityHint=""
+                      label="I have a confirmation code"
+                      labelContainerStyle={{
+                        justifyContent: 'center',
+                        padding: 4,
+                      }}
+                      labelStyle={[s.f18]}
+                      onPress={() => setStage(Stages.ConfirmCode)}
+                    />
+                  </>
+                )}
+                {stage === Stages.ConfirmCode && (
+                  <Button
+                    testID="confirmBtn"
+                    type="primary"
+                    onPress={onConfirm}
+                    accessibilityLabel="Confirm"
+                    accessibilityHint=""
+                    label="Confirm"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                <Button
+                  testID="cancelBtn"
+                  type="default"
+                  onPress={() => store.shell.closeModal()}
+                  accessibilityLabel={
+                    stage === Stages.Reminder ? 'Not right now' : 'Cancel'
+                  }
+                  accessibilityHint=""
+                  label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              </View>
+            )}
+          </View>
+        </ScrollView>
+      </SafeAreaView>
+    </KeyboardAvoidingView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isWeb ? 0 : 4,
+    paddingBottom: isWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  error: {
+    borderRadius: 6,
+    marginTop: 10,
+  },
+  emailContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    paddingHorizontal: 14,
+    marginTop: 10,
+  },
+  changeEmailLink: {
+    marginHorizontal: 12,
+    marginBottom: 12,
+  },
+  textInput: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+    fontSize: 16,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+})
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 076fa1baa..270d98317 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -42,6 +42,7 @@ export function Button({
   type = 'primary',
   label,
   style,
+  labelContainerStyle,
   labelStyle,
   onPress,
   children,
@@ -55,6 +56,7 @@ export function Button({
   type?: ButtonType
   label?: string
   style?: StyleProp<ViewStyle>
+  labelContainerStyle?: StyleProp<ViewStyle>
   labelStyle?: StyleProp<TextStyle>
   onPress?: () => void | Promise<void>
   testID?: string
@@ -173,7 +175,7 @@ export function Button({
     }
 
     return (
-      <View style={styles.labelContainer}>
+      <View style={[styles.labelContainer, labelContainerStyle]}>
         {label && withLoading && isLoading ? (
           <ActivityIndicator size={12} color={typeLabelStyle.color} />
         ) : null}
@@ -182,7 +184,15 @@ export function Button({
         </Text>
       </View>
     )
-  }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
+  }, [
+    children,
+    label,
+    withLoading,
+    isLoading,
+    labelContainerStyle,
+    typeLabelStyle,
+    labelStyle,
+  ])
 
   return (
     <Pressable
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 7b17b5347..66b2b8fbb 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -219,10 +219,25 @@ export const SettingsScreen = withAuthRequired(
               <View style={[styles.infoLine]}>
                 <Text type="lg-medium" style={pal.text}>
                   Email:{' '}
-                  <Text type="lg" style={pal.text}>
-                    {store.session.currentSession?.email}
-                  </Text>
                 </Text>
+                {!store.session.emailNeedsConfirmation && (
+                  <>
+                    <FontAwesomeIcon
+                      icon="check"
+                      size={10}
+                      style={{color: colors.green3, marginRight: 2}}
+                    />
+                  </>
+                )}
+                <Text type="lg" style={pal.text}>
+                  {store.session.currentSession?.email}{' '}
+                </Text>
+                <Link
+                  onPress={() => store.shell.openModal({name: 'change-email'})}>
+                  <Text type="lg" style={pal.link}>
+                    Change
+                  </Text>
+                </Link>
               </View>
               <View style={[styles.infoLine]}>
                 <Text type="lg-medium" style={pal.text}>
@@ -238,6 +253,7 @@ export const SettingsScreen = withAuthRequired(
                 </Link>
               </View>
               <View style={styles.spacer20} />
+              <EmailConfirmationNotice />
             </>
           ) : null}
           <View style={[s.flexRow, styles.heading]}>
@@ -665,6 +681,67 @@ function AccountDropdownBtn({handle}: {handle: string}) {
   )
 }
 
+const EmailConfirmationNotice = observer(
+  function EmailConfirmationNoticeImpl() {
+    const pal = usePalette('default')
+    const palInverted = usePalette('inverted')
+    const store = useStores()
+    const {isMobile} = useWebMediaQueries()
+
+    if (!store.session.emailNeedsConfirmation) {
+      return null
+    }
+
+    return (
+      <View style={{marginBottom: 20}}>
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          Verify email
+        </Text>
+        <View
+          style={[
+            {
+              paddingVertical: isMobile ? 12 : 0,
+              paddingHorizontal: 18,
+            },
+            pal.view,
+          ]}>
+          <View style={{flexDirection: 'row', marginBottom: 8}}>
+            <Pressable
+              style={[
+                palInverted.view,
+                {
+                  flexDirection: 'row',
+                  gap: 6,
+                  borderRadius: 6,
+                  paddingHorizontal: 12,
+                  paddingVertical: 10,
+                  alignItems: 'center',
+                },
+                isMobile && {flex: 1},
+              ]}
+              accessibilityRole="button"
+              accessibilityLabel="Verify my email"
+              accessibilityHint=""
+              onPress={() => store.shell.openModal({name: 'verify-email'})}>
+              <FontAwesomeIcon
+                icon="envelope"
+                color={palInverted.colors.text}
+                size={16}
+              />
+              <Text type="button" style={palInverted.text}>
+                Verify My Email
+              </Text>
+            </Pressable>
+          </View>
+          <Text style={pal.textLight}>
+            Protect your account by verifying your email.
+          </Text>
+        </View>
+      </View>
+    )
+  },
+)
+
 const styles = StyleSheet.create({
   dimmed: {
     opacity: 0.5,
diff --git a/yarn.lock b/yarn.lock
index 7533bb439..7a81e64e0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -47,15 +47,15 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
-"@atproto/api@^0.6.16":
-  version "0.6.16"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.16.tgz#0e5f259a8eb8af239b4e77bf70d7e770b33f4eeb"
-  integrity sha512-DpG994bdwk7NWJSb36Af+0+FRWMFZgzTcrK0rN2tvlsMh6wBF/RdErjHKuoL8wcogGzbI2yp8eOqsA00lyoisw==
-  dependencies:
-    "@atproto/common-web" "^0.2.0"
-    "@atproto/lexicon" "^0.2.1"
-    "@atproto/syntax" "^0.1.1"
-    "@atproto/xrpc" "^0.3.1"
+"@atproto/api@^0.6.20":
+  version "0.6.20"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.20.tgz#3a7eda60d73a5d5b6938e2dd016c24a7ba180c83"
+  integrity sha512-+peoKgkaxbglXQg9qEZcZIvyWm39yj0+syV3TBDrz5cWK4OIsdOyYBg2iISy+jvB5RzEUMe2WvOojP6Nq34mOg==
+  dependencies:
+    "@atproto/common-web" "^0.2.1"
+    "@atproto/lexicon" "^0.2.2"
+    "@atproto/syntax" "^0.1.2"
+    "@atproto/xrpc" "^0.3.2"
     multiformats "^9.9.0"
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
@@ -105,6 +105,16 @@
     uint8arrays "3.0.0"
     zod "^3.21.4"
 
+"@atproto/common-web@^0.2.1":
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.1.tgz#97412cb241321fc6c56a2b8c0b2416b3240caf50"
+  integrity sha512-5AoDKkKz7JhXSiicjhPihA/MJMlSuTQ9Aed9fflPuoTuT6C3aXbxaUZEcqqipSwlCfGpOzPmJmWJjMWWsYr2ew==
+  dependencies:
+    graphemer "^1.4.0"
+    multiformats "^9.9.0"
+    uint8arrays "3.0.0"
+    zod "^3.21.4"
+
 "@atproto/common@0.1.0":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -209,6 +219,17 @@
     multiformats "^9.9.0"
     zod "^3.21.4"
 
+"@atproto/lexicon@^0.2.2":
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.2.tgz#938a39482ff41c6a908f4ad43274adba595f3643"
+  integrity sha512-CvmjaSDavHMOJTuNYE8VjYhL7TVxBYV8QSWh2jHCpzfmj02DvVD9UBIfnoVv67POJkEtWXddjoV9beaIbaq/Xg==
+  dependencies:
+    "@atproto/common-web" "^0.2.1"
+    "@atproto/syntax" "^0.1.2"
+    iso-datestring-validator "^2.2.2"
+    multiformats "^9.9.0"
+    zod "^3.21.4"
+
 "@atproto/pds@^0.1.14":
   version "0.1.14"
   resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.14.tgz#7c5a49e412d599d2105bb7ecd019832ab952b19f"
@@ -276,6 +297,13 @@
   dependencies:
     "@atproto/common-web" "^0.2.0"
 
+"@atproto/syntax@^0.1.2":
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.2.tgz#417366d36b53ecf29d9d1f6e35179b1f3feef95b"
+  integrity sha512-n6VSuccMGouwftCvZBq9WNwI0qYCMOH/lTHSV+/dT232lX7pIrqisOlErUSBoOJ49B1Wxy1DjeeBS26ap9SsGQ==
+  dependencies:
+    "@atproto/common-web" "^0.2.1"
+
 "@atproto/xrpc-server@^0.3.1":
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.3.1.tgz#40eeae1dee79fcc835d7a0068ca90f9c91f0ba06"
@@ -301,6 +329,14 @@
     "@atproto/lexicon" "^0.2.1"
     zod "^3.21.4"
 
+"@atproto/xrpc@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.2.tgz#432a364be4b3bf8660a088a07dadecac10209763"
+  integrity sha512-D9jGjcFnEMHuGQ56v6+78uX3RiytKLrA5ITLq6shy0Qj6Zvt5MqV+/cTFuNPKrNCrnWOtHFeRQwMqyGhNS9qZQ==
+  dependencies:
+    "@atproto/lexicon" "^0.2.2"
+    zod "^3.21.4"
+
 "@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4":
   version "7.10.4"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"