about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/screens/Login/LoginForm.tsx52
-rw-r--r--src/state/modals/index.tsx1
-rw-r--r--src/state/persisted/schema.ts1
-rw-r--r--src/state/session/index.tsx16
-rw-r--r--src/view/com/modals/VerifyEmail.tsx36
-rw-r--r--src/view/screens/Settings/DisableEmail2FADialog.tsx195
-rw-r--r--src/view/screens/Settings/Email2FAToggle.tsx60
-rw-r--r--src/view/screens/Settings/index.tsx8
8 files changed, 350 insertions, 19 deletions
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
index 711619e85..17fc32368 100644
--- a/src/screens/Login/LoginForm.tsx
+++ b/src/screens/Login/LoginForm.tsx
@@ -6,7 +6,10 @@ import {
   TextInput,
   View,
 } from 'react-native'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {
+  ComAtprotoServerCreateSession,
+  ComAtprotoServerDescribeServer,
+} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -23,6 +26,7 @@ import {HostingProvider} from '#/components/forms/HostingProvider'
 import * as TextField from '#/components/forms/TextField'
 import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
 import {FormContainer} from './FormContainer'
@@ -53,8 +57,11 @@ export const LoginForm = ({
   const {track} = useAnalytics()
   const t = useTheme()
   const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
+    useState<boolean>(false)
   const [identifier, setIdentifier] = useState<string>(initialHandle)
   const [password, setPassword] = useState<string>('')
+  const [authFactorToken, setAuthFactorToken] = useState<string>('')
   const passwordInputRef = useRef<TextInput>(null)
   const {_} = useLingui()
   const {login} = useSessionApi()
@@ -100,6 +107,7 @@ export const LoginForm = ({
           service: serviceUrl,
           identifier: fullIdent,
           password,
+          authFactorToken: authFactorToken.trim(),
         },
         'LoginForm',
       )
@@ -107,7 +115,16 @@ export const LoginForm = ({
       const errMsg = e.toString()
       LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
       setIsProcessing(false)
-      if (errMsg.includes('Authentication Required')) {
+      if (
+        e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
+      ) {
+        setIsAuthFactorTokenNeeded(true)
+      } else if (errMsg.includes('Token is invalid')) {
+        logger.debug('Failed to login due to invalid 2fa token', {
+          error: errMsg,
+        })
+        setError(_(msg`Invalid 2FA confirmation code.`))
+      } else if (errMsg.includes('Authentication Required')) {
         logger.debug('Failed to login due to invalid credentials', {
           error: errMsg,
         })
@@ -215,6 +232,37 @@ export const LoginForm = ({
           </TextField.Root>
         </View>
       </View>
+      {isAuthFactorTokenNeeded && (
+        <View>
+          <TextField.LabelText>
+            <Trans>2FA Confirmation</Trans>
+          </TextField.LabelText>
+          <TextField.Root>
+            <TextField.Icon icon={Ticket} />
+            <TextField.Input
+              testID="loginAuthFactorTokenInput"
+              label={_(msg`Confirmation code`)}
+              autoCapitalize="none"
+              autoFocus
+              autoCorrect={false}
+              autoComplete="off"
+              returnKeyType="done"
+              textContentType="username"
+              blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
+              value={authFactorToken}
+              onChangeText={setAuthFactorToken}
+              onSubmitEditing={onPressNext}
+              editable={!isProcessing}
+              accessibilityHint={_(
+                msg`Input the code which has been emailed to you`,
+              )}
+            />
+          </TextField.Root>
+          <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}>
+            <Trans>Check your email for a login code and enter it here.</Trans>
+          </Text>
+        </View>
+      )}
       <FormError error={error} />
       <View style={[a.flex_row, a.align_center, a.pt_md]}>
         <Button
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index cc0f9c8b8..0f61a9711 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -107,6 +107,7 @@ export interface PostLanguagesSettingsModal {
 export interface VerifyEmailModal {
   name: 'verify-email'
   showReminder?: boolean
+  onSuccess?: () => void
 }
 
 export interface ChangeEmailModal {
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 1b77d138b..4076a582a 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -11,6 +11,7 @@ const accountSchema = z.object({
   handle: z.string(),
   email: z.string().optional(),
   emailConfirmed: z.boolean().optional(),
+  emailAuthFactor: z.boolean().optional(),
   refreshJwt: z.string().optional(), // optional because it can expire
   accessJwt: z.string().optional(), // optional because it can expire
   deactivated: z.boolean().optional(),
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 1d60eaf8f..ad5af130a 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -59,6 +59,7 @@ export type ApiContext = {
       service: string
       identifier: string
       password: string
+      authFactorToken?: string | undefined
     },
     logContext: LogEvents['account:loggedIn']['logContext'],
   ) => Promise<void>
@@ -87,7 +88,10 @@ export type ApiContext = {
   ) => Promise<void>
   updateCurrentAccount: (
     account: Partial<
-      Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
+      Pick<
+        SessionAccount,
+        'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
+      >
     >,
   ) => void
 }
@@ -298,12 +302,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 
   const login = React.useCallback<ApiContext['login']>(
-    async ({service, identifier, password}, logContext) => {
+    async ({service, identifier, password, authFactorToken}, logContext) => {
       logger.debug(`session: login`, {}, logger.DebugContext.session)
 
       const agent = new BskyAgent({service})
 
-      await agent.login({identifier, password})
+      await agent.login({identifier, password, authFactorToken})
 
       if (!agent.session) {
         throw new Error(`session: login failed to establish a session`)
@@ -319,6 +323,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         handle: agent.session.handle,
         email: agent.session.email,
         emailConfirmed: agent.session.emailConfirmed || false,
+        emailAuthFactor: agent.session.emailAuthFactor,
         refreshJwt: agent.session.refreshJwt,
         accessJwt: agent.session.accessJwt,
         deactivated: isSessionDeactivated(agent.session.accessJwt),
@@ -489,6 +494,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           handle: agent.session.handle,
           email: agent.session.email,
           emailConfirmed: agent.session.emailConfirmed || false,
+          emailAuthFactor: agent.session.emailAuthFactor || false,
           refreshJwt: agent.session.refreshJwt,
           accessJwt: agent.session.accessJwt,
           deactivated: isSessionDeactivated(agent.session.accessJwt),
@@ -546,6 +552,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             account.emailConfirmed !== undefined
               ? account.emailConfirmed
               : currentAccount.emailConfirmed,
+          emailAuthFactor:
+            account.emailAuthFactor !== undefined
+              ? account.emailAuthFactor
+              : currentAccount.emailAuthFactor,
         }
 
         return {
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index d3086d383..d25a9d30b 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -6,23 +6,24 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
-import {Svg, Circle, Path} from 'react-native-svg'
-import {ScrollView, TextInput} from './util'
+import {Circle, Path, Svg} from 'react-native-svg'
 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 {s, colors} from 'lib/styles'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {getAgent, useSession, useSessionApi} from '#/state/session'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {cleanError} from 'lib/strings/errors'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {useSession, useSessionApi, getAgent} from '#/state/session'
-import {logger} from '#/logger'
+import {colors, s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
+import {ScrollView, TextInput} from './util'
 
 export const snapPoints = ['90%']
 
@@ -32,7 +33,13 @@ enum Stages {
   ConfirmCode,
 }
 
-export function Component({showReminder}: {showReminder?: boolean}) {
+export function Component({
+  showReminder,
+  onSuccess,
+}: {
+  showReminder?: boolean
+  onSuccess?: () => void
+}) {
   const pal = usePalette('default')
   const {currentAccount} = useSession()
   const {updateCurrentAccount} = useSessionApi()
@@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
       updateCurrentAccount({emailConfirmed: true})
       Toast.show(_(msg`Email verified`))
       closeModal()
+      onSuccess?.()
     } catch (e) {
       setError(cleanError(String(e)))
     } finally {
diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/view/screens/Settings/DisableEmail2FADialog.tsx
new file mode 100644
index 000000000..4e9a810ca
--- /dev/null
+++ b/src/view/screens/Settings/DisableEmail2FADialog.tsx
@@ -0,0 +1,195 @@
+import React, {useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {cleanError} from '#/lib/strings/errors'
+import {isNative} from '#/platform/detection'
+import {getAgent, useSession, useSessionApi} from '#/state/session'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Loader} from '#/components/Loader'
+import {P, Text} from '#/components/Typography'
+
+enum Stages {
+  Email,
+  ConfirmCode,
+}
+
+export function DisableEmail2FADialog({
+  control,
+}: {
+  control: Dialog.DialogOuterProps['control']
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const {currentAccount} = useSession()
+  const {updateCurrentAccount} = useSessionApi()
+
+  const [stage, setStage] = useState<Stages>(Stages.Email)
+  const [confirmationCode, setConfirmationCode] = useState<string>('')
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [error, setError] = useState<string>('')
+
+  const onSendEmail = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await getAgent().com.atproto.server.requestEmailUpdate()
+      setStage(Stages.ConfirmCode)
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onConfirmDisable = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      if (currentAccount?.email) {
+        await getAgent().com.atproto.server.updateEmail({
+          email: currentAccount!.email,
+          token: confirmationCode.trim(),
+          emailAuthFactor: false,
+        })
+        updateCurrentAccount({emailAuthFactor: false})
+        Toast.show(_(msg`Email 2FA disabled`))
+      }
+      control.close()
+    } catch (e) {
+      const errMsg = String(e)
+      if (errMsg.includes('Token is invalid')) {
+        setError(_(msg`Invalid 2FA confirmation code.`))
+      } else {
+        setError(cleanError(errMsg))
+      }
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        accessibilityDescribedBy="dialog-description"
+        accessibilityLabelledBy="dialog-title">
+        <View style={[a.relative, a.gap_md, a.w_full]}>
+          <Text
+            nativeID="dialog-title"
+            style={[a.text_2xl, a.font_bold, t.atoms.text]}>
+            <Trans>Disable Email 2FA</Trans>
+          </Text>
+          <P
+            nativeID="dialog-description"
+            style={[a.text_sm, t.atoms.text, a.leading_snug]}>
+            {stage === Stages.ConfirmCode ? (
+              <Trans>
+                An email has been sent to{' '}
+                {currentAccount?.email || '(no email)'}. It includes a
+                confirmation code which you can enter below.
+              </Trans>
+            ) : (
+              <Trans>
+                To disable the email 2FA method, please verify your access to
+                the email address.
+              </Trans>
+            )}
+          </P>
+
+          {error ? <ErrorMessage message={error} /> : undefined}
+
+          {stage === Stages.Email ? (
+            <View style={gtMobile && [a.flex_row, a.justify_end, a.gap_md]}>
+              <Button
+                testID="sendEmailButton"
+                variant="solid"
+                color="primary"
+                size={gtMobile ? 'small' : 'large'}
+                onPress={onSendEmail}
+                label={_(msg`Send verification email`)}
+                disabled={isProcessing}>
+                <ButtonText>
+                  <Trans>Send verification email</Trans>
+                </ButtonText>
+                {isProcessing && <ButtonIcon icon={Loader} />}
+              </Button>
+              <Button
+                testID="haveCodeButton"
+                variant="ghost"
+                color="primary"
+                size={gtMobile ? 'small' : 'large'}
+                onPress={() => setStage(Stages.ConfirmCode)}
+                label={_(msg`I have a code`)}
+                disabled={isProcessing}>
+                <ButtonText>
+                  <Trans>I have a code</Trans>
+                </ButtonText>
+              </Button>
+            </View>
+          ) : stage === Stages.ConfirmCode ? (
+            <View>
+              <View style={[a.mb_md]}>
+                <TextField.LabelText>
+                  <Trans>Confirmation code</Trans>
+                </TextField.LabelText>
+                <TextField.Root>
+                  <TextField.Icon icon={Lock} />
+                  <TextField.Input
+                    testID="confirmationCode"
+                    label={_(msg`Confirmation code`)}
+                    autoCapitalize="none"
+                    autoFocus
+                    autoCorrect={false}
+                    autoComplete="off"
+                    value={confirmationCode}
+                    onChangeText={setConfirmationCode}
+                    editable={!isProcessing}
+                  />
+                </TextField.Root>
+              </View>
+              <View style={gtMobile && [a.flex_row, a.justify_end]}>
+                <Button
+                  testID="resendCodeBtn"
+                  variant="ghost"
+                  color="primary"
+                  size={gtMobile ? 'small' : 'large'}
+                  onPress={onSendEmail}
+                  label={_(msg`Resend email`)}
+                  disabled={isProcessing}>
+                  <ButtonText>
+                    <Trans>Resend email</Trans>
+                  </ButtonText>
+                </Button>
+                <Button
+                  testID="confirmBtn"
+                  variant="solid"
+                  color="primary"
+                  size={gtMobile ? 'small' : 'large'}
+                  onPress={onConfirmDisable}
+                  label={_(msg`Confirm`)}
+                  disabled={isProcessing}>
+                  <ButtonText>
+                    <Trans>Confirm</Trans>
+                  </ButtonText>
+                  {isProcessing && <ButtonIcon icon={Loader} />}
+                </Button>
+              </View>
+            </View>
+          ) : undefined}
+
+          {!gtMobile && isNative && <View style={{height: 40}} />}
+        </View>
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
diff --git a/src/view/screens/Settings/Email2FAToggle.tsx b/src/view/screens/Settings/Email2FAToggle.tsx
new file mode 100644
index 000000000..93f1b2042
--- /dev/null
+++ b/src/view/screens/Settings/Email2FAToggle.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useModalControls} from '#/state/modals'
+import {getAgent, useSession, useSessionApi} from '#/state/session'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {useDialogControl} from '#/components/Dialog'
+import {DisableEmail2FADialog} from './DisableEmail2FADialog'
+
+export function Email2FAToggle() {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {updateCurrentAccount} = useSessionApi()
+  const {openModal} = useModalControls()
+  const disableDialogCtrl = useDialogControl()
+
+  const enableEmailAuthFactor = React.useCallback(async () => {
+    if (currentAccount?.email) {
+      await getAgent().com.atproto.server.updateEmail({
+        email: currentAccount.email,
+        emailAuthFactor: true,
+      })
+      updateCurrentAccount({
+        emailAuthFactor: true,
+      })
+    }
+  }, [currentAccount, updateCurrentAccount])
+
+  const onToggle = React.useCallback(() => {
+    if (!currentAccount) {
+      return
+    }
+    if (currentAccount.emailAuthFactor) {
+      disableDialogCtrl.open()
+    } else {
+      if (!currentAccount.emailConfirmed) {
+        openModal({
+          name: 'verify-email',
+          onSuccess: enableEmailAuthFactor,
+        })
+        return
+      }
+      enableEmailAuthFactor()
+    }
+  }, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl])
+
+  return (
+    <>
+      <DisableEmail2FADialog control={disableDialogCtrl} />
+      <ToggleButton
+        type="default-light"
+        label={_(msg`Require email code to log into your account`)}
+        labelType="lg"
+        isSelected={!!currentAccount?.emailAuthFactor}
+        onPress={onToggle}
+      />
+    </>
+  )
+}
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index bb38da676..1211aa5c5 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -64,6 +64,7 @@ import {ScrollView} from 'view/com/util/Views'
 import {useDialogControl} from '#/components/Dialog'
 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
 import {navigate, resetToTab} from '#/Navigation'
+import {Email2FAToggle} from './Email2FAToggle'
 import {ExportCarDialog} from './ExportCarDialog'
 
 function SettingsAccountCard({account}: {account: SessionAccount}) {
@@ -691,6 +692,13 @@ export function SettingsScreen({}: Props) {
         )}
         <View style={styles.spacer20} />
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Two-factor authentication</Trans>
+        </Text>
+        <View style={[pal.view, styles.toggleCard]}>
+          <Email2FAToggle />
+        </View>
+        <View style={styles.spacer20} />
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
           <Trans>Account</Trans>
         </Text>
         <TouchableOpacity