Email auth factor (#3602)
* Add email 2fa toggle * Add UI elements needed for 2fa codes in login * Wire up to the server * Give a better failure message for bad 2fa code * Handle enter key in login form 2fa field * Trim spaces * Improve error messagezio/stable
parent
cbb817b5b7
commit
710e913024
|
@ -50,7 +50,7 @@
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.3",
|
"@atproto/api": "^0.12.5",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
|
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
|
||||||
|
|
|
@ -6,7 +6,10 @@ import {
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {ComAtprotoServerDescribeServer} from '@atproto/api'
|
import {
|
||||||
|
ComAtprotoServerCreateSession,
|
||||||
|
ComAtprotoServerDescribeServer,
|
||||||
|
} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
@ -23,6 +26,7 @@ import {HostingProvider} from '#/components/forms/HostingProvider'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
||||||
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
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 {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {FormContainer} from './FormContainer'
|
import {FormContainer} from './FormContainer'
|
||||||
|
@ -53,8 +57,11 @@ export const LoginForm = ({
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
|
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
||||||
|
useState<boolean>(false)
|
||||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
||||||
const [password, setPassword] = useState<string>('')
|
const [password, setPassword] = useState<string>('')
|
||||||
|
const [authFactorToken, setAuthFactorToken] = useState<string>('')
|
||||||
const passwordInputRef = useRef<TextInput>(null)
|
const passwordInputRef = useRef<TextInput>(null)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {login} = useSessionApi()
|
const {login} = useSessionApi()
|
||||||
|
@ -100,6 +107,7 @@ export const LoginForm = ({
|
||||||
service: serviceUrl,
|
service: serviceUrl,
|
||||||
identifier: fullIdent,
|
identifier: fullIdent,
|
||||||
password,
|
password,
|
||||||
|
authFactorToken: authFactorToken.trim(),
|
||||||
},
|
},
|
||||||
'LoginForm',
|
'LoginForm',
|
||||||
)
|
)
|
||||||
|
@ -107,7 +115,16 @@ export const LoginForm = ({
|
||||||
const errMsg = e.toString()
|
const errMsg = e.toString()
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
setIsProcessing(false)
|
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', {
|
logger.debug('Failed to login due to invalid credentials', {
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
})
|
})
|
||||||
|
@ -215,6 +232,37 @@ export const LoginForm = ({
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
</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} />
|
<FormError error={error} />
|
||||||
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
<View style={[a.flex_row, a.align_center, a.pt_md]}>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -107,6 +107,7 @@ export interface PostLanguagesSettingsModal {
|
||||||
export interface VerifyEmailModal {
|
export interface VerifyEmailModal {
|
||||||
name: 'verify-email'
|
name: 'verify-email'
|
||||||
showReminder?: boolean
|
showReminder?: boolean
|
||||||
|
onSuccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeEmailModal {
|
export interface ChangeEmailModal {
|
||||||
|
|
|
@ -11,6 +11,7 @@ const accountSchema = z.object({
|
||||||
handle: z.string(),
|
handle: z.string(),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
emailConfirmed: z.boolean().optional(),
|
emailConfirmed: z.boolean().optional(),
|
||||||
|
emailAuthFactor: z.boolean().optional(),
|
||||||
refreshJwt: z.string().optional(), // optional because it can expire
|
refreshJwt: z.string().optional(), // optional because it can expire
|
||||||
accessJwt: z.string().optional(), // optional because it can expire
|
accessJwt: z.string().optional(), // optional because it can expire
|
||||||
deactivated: z.boolean().optional(),
|
deactivated: z.boolean().optional(),
|
||||||
|
|
|
@ -59,6 +59,7 @@ export type ApiContext = {
|
||||||
service: string
|
service: string
|
||||||
identifier: string
|
identifier: string
|
||||||
password: string
|
password: string
|
||||||
|
authFactorToken?: string | undefined
|
||||||
},
|
},
|
||||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
@ -87,7 +88,10 @@ export type ApiContext = {
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
updateCurrentAccount: (
|
updateCurrentAccount: (
|
||||||
account: Partial<
|
account: Partial<
|
||||||
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
|
Pick<
|
||||||
|
SessionAccount,
|
||||||
|
'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
|
||||||
|
>
|
||||||
>,
|
>,
|
||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
@ -298,12 +302,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
)
|
)
|
||||||
|
|
||||||
const login = React.useCallback<ApiContext['login']>(
|
const login = React.useCallback<ApiContext['login']>(
|
||||||
async ({service, identifier, password}, logContext) => {
|
async ({service, identifier, password, authFactorToken}, logContext) => {
|
||||||
logger.debug(`session: login`, {}, logger.DebugContext.session)
|
logger.debug(`session: login`, {}, logger.DebugContext.session)
|
||||||
|
|
||||||
const agent = new BskyAgent({service})
|
const agent = new BskyAgent({service})
|
||||||
|
|
||||||
await agent.login({identifier, password})
|
await agent.login({identifier, password, authFactorToken})
|
||||||
|
|
||||||
if (!agent.session) {
|
if (!agent.session) {
|
||||||
throw new Error(`session: login failed to establish a 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,
|
handle: agent.session.handle,
|
||||||
email: agent.session.email,
|
email: agent.session.email,
|
||||||
emailConfirmed: agent.session.emailConfirmed || false,
|
emailConfirmed: agent.session.emailConfirmed || false,
|
||||||
|
emailAuthFactor: agent.session.emailAuthFactor,
|
||||||
refreshJwt: agent.session.refreshJwt,
|
refreshJwt: agent.session.refreshJwt,
|
||||||
accessJwt: agent.session.accessJwt,
|
accessJwt: agent.session.accessJwt,
|
||||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||||
|
@ -489,6 +494,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
handle: agent.session.handle,
|
handle: agent.session.handle,
|
||||||
email: agent.session.email,
|
email: agent.session.email,
|
||||||
emailConfirmed: agent.session.emailConfirmed || false,
|
emailConfirmed: agent.session.emailConfirmed || false,
|
||||||
|
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||||
refreshJwt: agent.session.refreshJwt,
|
refreshJwt: agent.session.refreshJwt,
|
||||||
accessJwt: agent.session.accessJwt,
|
accessJwt: agent.session.accessJwt,
|
||||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||||
|
@ -546,6 +552,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
account.emailConfirmed !== undefined
|
account.emailConfirmed !== undefined
|
||||||
? account.emailConfirmed
|
? account.emailConfirmed
|
||||||
: currentAccount.emailConfirmed,
|
: currentAccount.emailConfirmed,
|
||||||
|
emailAuthFactor:
|
||||||
|
account.emailAuthFactor !== undefined
|
||||||
|
? account.emailAuthFactor
|
||||||
|
: currentAccount.emailAuthFactor,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -6,23 +6,24 @@ import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Svg, Circle, Path} from 'react-native-svg'
|
import {Circle, Path, Svg} from 'react-native-svg'
|
||||||
import {ScrollView, TextInput} from './util'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {Text} from '../util/text/Text'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {Button} from '../util/forms/Button'
|
import {useLingui} from '@lingui/react'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
|
||||||
import * as Toast from '../util/Toast'
|
import {logger} from '#/logger'
|
||||||
import {s, colors} from 'lib/styles'
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {getAgent, useSession, useSessionApi} from '#/state/session'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {colors, s} from 'lib/styles'
|
||||||
import {useLingui} from '@lingui/react'
|
import {isWeb} from 'platform/detection'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {useSession, useSessionApi, getAgent} from '#/state/session'
|
import {Button} from '../util/forms/Button'
|
||||||
import {logger} from '#/logger'
|
import {Text} from '../util/text/Text'
|
||||||
|
import * as Toast from '../util/Toast'
|
||||||
|
import {ScrollView, TextInput} from './util'
|
||||||
|
|
||||||
export const snapPoints = ['90%']
|
export const snapPoints = ['90%']
|
||||||
|
|
||||||
|
@ -32,7 +33,13 @@ enum Stages {
|
||||||
ConfirmCode,
|
ConfirmCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Component({showReminder}: {showReminder?: boolean}) {
|
export function Component({
|
||||||
|
showReminder,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
showReminder?: boolean
|
||||||
|
onSuccess?: () => void
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {updateCurrentAccount} = useSessionApi()
|
const {updateCurrentAccount} = useSessionApi()
|
||||||
|
@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
|
||||||
updateCurrentAccount({emailConfirmed: true})
|
updateCurrentAccount({emailConfirmed: true})
|
||||||
Toast.show(_(msg`Email verified`))
|
Toast.show(_(msg`Email verified`))
|
||||||
closeModal()
|
closeModal()
|
||||||
|
onSuccess?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(cleanError(String(e)))
|
setError(cleanError(String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -64,6 +64,7 @@ import {ScrollView} from 'view/com/util/Views'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||||
import {navigate, resetToTab} from '#/Navigation'
|
import {navigate, resetToTab} from '#/Navigation'
|
||||||
|
import {Email2FAToggle} from './Email2FAToggle'
|
||||||
import {ExportCarDialog} from './ExportCarDialog'
|
import {ExportCarDialog} from './ExportCarDialog'
|
||||||
|
|
||||||
function SettingsAccountCard({account}: {account: SessionAccount}) {
|
function SettingsAccountCard({account}: {account: SessionAccount}) {
|
||||||
|
@ -690,6 +691,13 @@ export function SettingsScreen({}: Props) {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={styles.spacer20} />
|
<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]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
<Trans>Account</Trans>
|
<Trans>Account</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -46,6 +46,18 @@
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
|
|
||||||
|
"@atproto/api@^0.12.5":
|
||||||
|
version "0.12.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.5.tgz#3ed70990b27c468d9663ca71306039cab663ca96"
|
||||||
|
integrity sha512-xqdl/KrAK2kW6hN8+eSmKTWHgMNaPnDAEvZzo08Xbk/5jdRzjoEPS+p7k/wQ+ZefwOHL3QUbVPO4hMfmVxzO/Q==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/common-web" "^0.3.0"
|
||||||
|
"@atproto/lexicon" "^0.4.0"
|
||||||
|
"@atproto/syntax" "^0.3.0"
|
||||||
|
"@atproto/xrpc" "^0.5.0"
|
||||||
|
multiformats "^9.9.0"
|
||||||
|
tlds "^1.234.0"
|
||||||
|
|
||||||
"@atproto/aws@^0.2.0":
|
"@atproto/aws@^0.2.0":
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"
|
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"
|
||||||
|
|
Loading…
Reference in New Issue