Add 2FA screens

This commit is contained in:
Eric Bailey
2025-04-28 11:17:14 -05:00
parent 05f1872ef7
commit cbe7260578
10 changed files with 536 additions and 61 deletions
@@ -1,10 +1,12 @@
import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import {useInvalidateIsEmailVerified} from '#/components/dialogs/EmailDialog/data/useIsEmailVerified'
export function useConfirmEmail() {
const agent = useAgent()
const {currentAccount} = useSession()
const invalidateIsEmailVerified = useInvalidateIsEmailVerified()
return useMutation({
mutationFn: async ({token}: {token: string}) => {
@@ -17,6 +19,7 @@ export function useConfirmEmail() {
token: token.trim(),
})
await agent.resumeSession(agent.session!)
await invalidateIsEmailVerified()
},
})
}
@@ -0,0 +1,49 @@
import {useCallback, useRef} from 'react'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
export const isEmailVerifiedQueryKey = ['isEmailVerified'] as const
export function useInvalidateIsEmailVerified() {
const qc = useQueryClient()
return useCallback(() => {
return qc.invalidateQueries({
queryKey: isEmailVerifiedQueryKey,
})
}, [qc])
}
export function useIsEmailVerified({
onEmailVerified,
}: {
onEmailVerified?: () => void
} = {}) {
const agent = useAgent()
const prevIsEmailVerified = useRef(!!agent.session?.emailConfirmed)
const query = useQuery({
enabled: !!agent.session,
initialData: {isEmailVerified: !!agent.session?.emailConfirmed},
refetchOnWindowFocus: true,
queryKey: isEmailVerifiedQueryKey,
queryFn: async () => {
const {data} = await agent.com.atproto.server.getSession()
return {
isEmailVerified: !!data.emailConfirmed,
}
},
})
// TODO double check racing?
if (query.data.isEmailVerified && !prevIsEmailVerified.current) {
console.log('fire')
prevIsEmailVerified.current = true
onEmailVerified?.()
} else if (prevIsEmailVerified.current && !query.data.isEmailVerified) {
console.log('reset')
prevIsEmailVerified.current = false
}
return query.data
}
@@ -0,0 +1,28 @@
import {useMutation} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
export function useManageEmail2FA() {
const agent = useAgent()
const {currentAccount} = useSession()
return useMutation({
mutationFn: async ({
enabled,
token,
}:
| {enabled: true; token?: undefined}
| {enabled: false; token: string}) => {
if (!currentAccount?.email) {
throw new Error('No email found for the current account')
}
await agent.com.atproto.server.updateEmail({
email: currentAccount.email,
emailAuthFactor: enabled,
token,
})
await agent.resumeSession(agent.session!)
},
})
}
+11 -5
View File
@@ -1,4 +1,4 @@
import {useCallback} from 'react'
import {useCallback, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,6 +8,7 @@ import {
useStatefulDialogControl,
} from '#/components/dialogs/Context'
import {useRefreshSession} from '#/components/dialogs/EmailDialog/data/useRefreshSession'
import {Manage2FA} from '#/components/dialogs/EmailDialog/screens/Manage2FA'
/*
* Steps
*/
@@ -50,14 +51,19 @@ export function EmailDialog({control}: {control: StatefulControl<Screen>}) {
}
function Inner({control}: {control: StatefulControl<Screen>}) {
if (!control.value) return null
const [screen, showScreen] = useState(() => control.value)
switch (control.value.id) {
if (!screen) return null
switch (screen.id) {
case ScreenID.Update: {
return <Update config={control.value} />
return <Update config={screen} />
}
case ScreenID.Verify: {
return <Verify config={control.value} />
return <Verify config={screen} />
}
case ScreenID.Manage2FA: {
return <Manage2FA config={screen} showScreen={showScreen} />
}
default: {
return null
@@ -0,0 +1,238 @@
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 {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 {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
stage: 'email' | 'token'
emailStatus: 'pending' | 'success' | 'error' | 'default'
tokenStatus: 'pending' | 'success' | 'error' | 'default'
}
type Action =
| {
id: 'setError'
error: string
}
| {
id: 'setStage'
stage: 'email' | 'token'
}
| {
id: 'setEmailStatus'
status: State['emailStatus']
}
| {
id: 'setTokenStatus'
status: State['tokenStatus']
}
function reducer(state: State, action: Action): State {
switch (action.id) {
case 'setError': {
return {
...state,
error: action.error,
emailStatus: 'error',
tokenStatus: 'error',
}
}
case 'setStage': {
return {
...state,
error: '',
stage: action.stage,
}
}
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 {currentAccount} = useSession()
const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
const {mutateAsync: manageEmail2FA} = useManageEmail2FA()
const control = useDialogContext()
const [token, setToken] = useState('')
const [state, dispatch] = useReducer(reducer, {
error: '',
stage: 'email',
emailStatus: 'default',
tokenStatus: 'default',
})
const handleSendEmail = async () => {
dispatch({id: 'setEmailStatus', status: 'pending'})
try {
await wait(1000, requestEmailUpdate())
dispatch({id: 'setEmailStatus', status: 'success'})
setTimeout(() => {
dispatch({id: 'setStage', stage: 'token'})
}, 1000)
} catch (e) {
logger.error('Manage2FA: email update code request failed', {
safeMessage: e,
})
// TODO rate limit
dispatch({
id: 'setError',
error: _(msg`Failed to send email, please try again.`),
})
}
}
const handleManageEmail2FA = async () => {
dispatch({id: 'setTokenStatus', status: 'pending'})
try {
await wait(1000, manageEmail2FA({enabled: false, token}))
dispatch({id: 'setTokenStatus', status: 'success'})
setTimeout(() => {
control.close()
}, 1000)
} catch (e) {
logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e})
dispatch({
id: 'setError',
error: _(msg`Update to email 2FA settings failed`),
})
}
}
return (
<View style={[a.gap_sm]}>
<Text style={[a.text_xl, a.font_heavy, a.leading_snug]}>
<Trans>Disable email 2FA</Trans>
</Text>
{state.stage === '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({id: 'setStage', stage: '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={() => {}}
/>
<ResendEmailText onPress={handleSendEmail} />
</View>
{state.error && <Admonition type="error">{state.error}</Admonition>}
<Button
label={_(msg`Disable 2FAVerify`)}
size="large"
variant="solid"
color="primary"
onPress={handleManageEmail2FA}
disabled={!token || state.tokenStatus === 'pending'}>
<ButtonText>
<Trans>Disable 2FA</Trans>
</ButtonText>
{state.tokenStatus === 'pending' ? (
<ButtonIcon icon={Loader} />
) : state.tokenStatus === 'success' ? (
<ButtonIcon icon={Loader} />
) : null}
</Button>
</>
)}
</View>
)
}
@@ -0,0 +1,130 @@
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 {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 =
| {
id: 'setError'
error: string
}
| {
id: 'setStatus'
status: State['status']
}
function reducer(state: State, action: Action): State {
switch (action.id) {
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 {gtPhone} = useBreakpoints()
const {mutateAsync: manageEmail2FA} = useManageEmail2FA()
const control = useDialogContext()
const [state, dispatch] = useReducer(reducer, {
error: '',
status: 'default',
})
const handleManageEmail2FA = async () => {
dispatch({id: 'setStatus', status: 'pending'})
try {
await wait(1000, manageEmail2FA({enabled: true}))
dispatch({id: 'setStatus', status: 'success'})
setTimeout(() => {
control.close()
}, 1000)
} catch (e) {
logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e})
dispatch({
id: 'setError',
error: _(msg`Update to email 2FA settings failed`),
})
}
}
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`Send verification email`)}
size="large"
variant="solid"
color="primary"
onPress={handleManageEmail2FA}
disabled={state.status === 'pending'}>
<ButtonText>Enable</ButtonText>
<ButtonIcon
position="right"
icon={
state.status === 'pending'
? Loader
: state.status === 'success'
? Check
: ShieldIcon
}
/>
</Button>
<Button
label={_(msg`Send verification email`)}
size="large"
variant="solid"
color="secondary"
onPress={() => control.close()}>
<ButtonText>Cancel</ButtonText>
</Button>
</View>
</View>
)
}
@@ -0,0 +1,60 @@
import {useState} from 'react'
import {Trans} from '@lingui/macro'
import {useSession} from '#/state/session'
import {useIsEmailVerified} from '#/components/dialogs/EmailDialog/data/useIsEmailVerified'
import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable'
/**
* Sub-screens
*/
import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable'
import {type Screen, ScreenID} from '#/components/dialogs/EmailDialog/types'
export function Manage2FA({
showScreen,
}: {
config: Extract<Screen, {id: 'Manage2FA'}>
showScreen: (screen: Screen) => void
}) {
const [requestedAction, setRequestedAction] = useState<
'enable' | 'disable' | null
>(null)
// TODO confirm this is in sync
const {currentAccount} = useSession()
const {isEmailVerified} = useIsEmailVerified()
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,
})
},
})
return null
}
if (currentAccount?.emailAuthFactor && requestedAction !== 'enable') {
if (!requestedAction) {
setRequestedAction('disable')
}
return <Disable />
} else if (
!currentAccount?.emailAuthFactor &&
requestedAction !== 'disable'
) {
if (!requestedAction) {
setRequestedAction('enable')
}
return <Enable />
}
return null
}
@@ -12,6 +12,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText'
import {TokenField} from '#/components/dialogs/EmailDialog/components/TokenField'
import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
import {useIsEmailVerified} from '#/components/dialogs/EmailDialog/data/useIsEmailVerified'
import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification'
import {type Screen} from '#/components/dialogs/EmailDialog/types'
import {Divider} from '#/components/Divider'
@@ -21,7 +22,7 @@ import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Span, Text} from '#/components/Typography'
export function Verify({config}: {config: Exclude<Screen, {id: 'Update'}>}) {
export function Verify({config}: {config: Extract<Screen, {id: 'Verify'}>}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -39,6 +40,12 @@ export function Verify({config}: {config: Exclude<Screen, {id: 'Update'}>}) {
)
const {mutateAsync: confirmEmail} = useConfirmEmail()
useIsEmailVerified({
onEmailVerified: () => {
config.onVerify?.()
},
})
const handleRequestEmailVerification = async () => {
setError('')
setSendingStatus('sending')
+3 -6
View File
@@ -16,17 +16,14 @@ export type Screen =
id: ScreenID.Verify
instructions?: ReactNode[]
hideInitialCodeButton?: boolean
onVerify?: () => void
}
| {
id: ScreenID.Enable2FA
}
| {
id: ScreenID.Disable2FA
id: ScreenID.Manage2FA
}
export enum ScreenID {
Update = 'Update',
Verify = 'Verify',
Enable2FA = 'Enable2FA',
Disable2FA = 'Disable2FA',
Manage2FA = 'Manage2FA',
}
@@ -1,15 +1,14 @@
import React from 'react'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgent, useSession} from '#/state/session'
import {useSession} from '#/state/session'
import {useDialogControl} from '#/components/Dialog'
import {
EmailDialog,
EmailDialogScreenID,
useEmailDialogControl,
} from '#/components/dialogs/EmailDialog'
import * as Prompt from '#/components/Prompt'
import {DisableEmail2FADialog} from './DisableEmail2FADialog'
import * as SettingsList from './SettingsList'
@@ -17,59 +16,17 @@ export function Email2FAToggle() {
const {_} = useLingui()
const {currentAccount} = useSession()
const disableDialogControl = useDialogControl()
const enableDialogControl = useDialogControl()
const agent = useAgent()
const emailDialogControl = useEmailDialogControl()
const enableEmailAuthFactor = React.useCallback(async () => {
if (currentAccount?.email) {
await agent.com.atproto.server.updateEmail({
email: currentAccount.email,
emailAuthFactor: true,
})
await agent.resumeSession(agent.session!)
}
}, [currentAccount, agent])
const onToggle = React.useCallback(() => {
if (!currentAccount) {
return
}
if (currentAccount.emailAuthFactor) {
disableDialogControl.open()
} else {
if (!currentAccount.emailConfirmed) {
emailDialogControl.open({
id: EmailDialogScreenID.Verify,
hideInitialCodeButton: true,
instructions: [
<Trans key="2fa">
You need to verify your email address before you can enable email
2FA.
</Trans>,
],
})
return
}
enableDialogControl.open()
}
}, [
currentAccount,
enableDialogControl,
disableDialogControl,
emailDialogControl,
])
emailDialogControl.open({
id: EmailDialogScreenID.Manage2FA,
})
}, [emailDialogControl])
return (
<>
<DisableEmail2FADialog control={disableDialogControl} />
<Prompt.Basic
control={enableDialogControl}
title={_(msg`Enable Email 2FA`)}
description={_(msg`Require an email code to sign in to your account.`)}
onConfirm={enableEmailAuthFactor}
confirmButtonCta={_(msg`Enable`)}
/>
<EmailDialog control={emailDialogControl} />
<SettingsList.BadgeButton
label={