From cbe72605788dbf7dcd3d9130e265a285bc6dee34 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 28 Apr 2025 11:17:14 -0500 Subject: [PATCH] Add 2FA screens --- .../EmailDialog/data/useConfirmEmail.ts | 3 + .../EmailDialog/data/useIsEmailVerified.ts | 49 ++++ .../EmailDialog/data/useManageEmail2FA.ts | 28 +++ src/components/dialogs/EmailDialog/index.tsx | 16 +- .../EmailDialog/screens/Manage2FA/Disable.tsx | 238 ++++++++++++++++++ .../EmailDialog/screens/Manage2FA/Enable.tsx | 130 ++++++++++ .../EmailDialog/screens/Manage2FA/index.tsx | 60 +++++ .../dialogs/EmailDialog/screens/Verify.tsx | 9 +- src/components/dialogs/EmailDialog/types.ts | 9 +- .../Settings/components/Email2FAToggle.tsx | 55 +--- 10 files changed, 536 insertions(+), 61 deletions(-) create mode 100644 src/components/dialogs/EmailDialog/data/useIsEmailVerified.ts create mode 100644 src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts create mode 100644 src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx create mode 100644 src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx create mode 100644 src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts index fa7c8b2d2f..3d17cd1afb 100644 --- a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -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() }, }) } diff --git a/src/components/dialogs/EmailDialog/data/useIsEmailVerified.ts b/src/components/dialogs/EmailDialog/data/useIsEmailVerified.ts new file mode 100644 index 0000000000..226cbc859d --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useIsEmailVerified.ts @@ -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 +} diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts new file mode 100644 index 0000000000..36737112b6 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -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!) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx index 72c87a0d12..52cb4d202f 100644 --- a/src/components/dialogs/EmailDialog/index.tsx +++ b/src/components/dialogs/EmailDialog/index.tsx @@ -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}) { } function Inner({control}: {control: StatefulControl}) { - 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 + return } case ScreenID.Verify: { - return + return + } + case ScreenID.Manage2FA: { + return } default: { return null diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx new file mode 100644 index 0000000000..118f1dd96d --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx @@ -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 ( + + + Disable email 2FA + + + {state.stage === 'email' ? ( + <> + + + To disable your email 2FA method, please verify your access to{' '} + {currentAccount?.email} + + + + + {state.error && {state.error}} + + + + + + + + Have a code?{' '} + { + dispatch({id: 'setStage', stage: 'token'}) + })}> + Click here. + + + + + + ) : ( + <> + + + To disable your email 2FA method, please verify your access to{' '} + {currentAccount?.email} + + + + + {}} + /> + + + + {state.error && {state.error}} + + + + )} + + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx new file mode 100644 index 0000000000..174321fe94 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx @@ -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 ( + + + + Enable email 2FA + + + + Require an email code to sign in to your account. + + + + {state.error && {state.error}} + + + + + + + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx new file mode 100644 index 0000000000..739b7d4816 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx @@ -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 + 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: [ + + You need to verify your email address before you can enable email 2FA. + , + ], + onVerify: () => { + showScreen({ + id: ScreenID.Manage2FA, + }) + }, + }) + return null + } + + if (currentAccount?.emailAuthFactor && requestedAction !== 'enable') { + if (!requestedAction) { + setRequestedAction('disable') + } + return + } else if ( + !currentAccount?.emailAuthFactor && + requestedAction !== 'disable' + ) { + if (!requestedAction) { + setRequestedAction('enable') + } + + return + } + + return null +} diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx index 2902861d96..223d51d8b6 100644 --- a/src/components/dialogs/EmailDialog/screens/Verify.tsx +++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx @@ -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}) { +export function Verify({config}: {config: Extract}) { const t = useTheme() const {_} = useLingui() const {currentAccount} = useSession() @@ -39,6 +40,12 @@ export function Verify({config}: {config: Exclude}) { ) const {mutateAsync: confirmEmail} = useConfirmEmail() + useIsEmailVerified({ + onEmailVerified: () => { + config.onVerify?.() + }, + }) + const handleRequestEmailVerification = async () => { setError('') setSendingStatus('sending') diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts index 3ca0afa51b..da6e5e091f 100644 --- a/src/components/dialogs/EmailDialog/types.ts +++ b/src/components/dialogs/EmailDialog/types.ts @@ -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', } diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx index d29d1960a9..c8f2577d0e 100644 --- a/src/screens/Settings/components/Email2FAToggle.tsx +++ b/src/screens/Settings/components/Email2FAToggle.tsx @@ -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: [ - - You need to verify your email address before you can enable email - 2FA. - , - ], - }) - return - } - enableDialogControl.open() - } - }, [ - currentAccount, - enableDialogControl, - disableDialogControl, - emailDialogControl, - ]) + emailDialogControl.open({ + id: EmailDialogScreenID.Manage2FA, + }) + }, [emailDialogControl]) return ( <> -