diff options
author | Eric Bailey <git@esb.lol> | 2025-05-07 10:23:33 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-05-07 10:23:33 -0500 |
commit | 0f96669f8c0d578d888c06496d97929130d34a1f (patch) | |
tree | cd053b6062fc5045eb14411135dc6ea46d5018f3 /src/components/dialogs | |
parent | 0edd3bd3b4445275ea3f9ddfc5f91ad4950acdd8 (diff) | |
download | voidsky-0f96669f8c0d578d888c06496d97929130d34a1f.tar.zst |
[APP-1158] Refactor email-related dialogs (#8296)
* WIP * Update email * Fire off confirmation email after change * Verify step, integrate stateful control * Remove tentative EnterCode step * Handle token step * Handle instructions and integrate into 2FA setting * Fix load state when reusing same email * Add new state * Add 2FA screens * Clean up state in Update step * Clean up verify state, handle normal callback * Normalize convetions * Add verification reminder screen * Improve session refresh * Handle verification requirements for composer and convo * Fix lint * Do better * Couple missing translations * Format * Use listeners for easier to grok logic * Clean errors * Move to global context * [APP-1158] Gate features by email verification state (#8305) * Use new hook in all locations * Format * Seems to work, not great duplication * Wrap all open composer calls * Remove unneeded spans * Missed one * Fix handler on Conversation * Gate new chat in header * Add comment * Remove whoopsie * Format * add hackfix for dialog not showing * add prompt to accept chat btn * navigation not necessary * send back one screen, rather than home * Update comment --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Clear dialog state Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Update icon * Check color * Add 2FA warning * Update instructions * Fix X button * Use an effect silly goose * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/dialaUpdate copyogs/EmailDialog/screens/Update.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy * Update copy * Update copy * Update copy * Update copy Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update copy * Add link back to update email from verify email dialog * Handle token field validation --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/components/dialogs')
18 files changed, 1725 insertions, 2 deletions
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index fda904b8b..728044325 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -1,6 +1,7 @@ import {createContext, useContext, useMemo, useState} from 'react' import * as Dialog from '#/components/Dialog' +import {type Screen} from '#/components/dialogs/EmailDialog/types' type Control = Dialog.DialogControlProps @@ -15,6 +16,7 @@ type ControlsContext = { mutedWordsDialogControl: Control signinDialogControl: Control inAppBrowserConsentControl: StatefulControl<string> + emailDialogControl: StatefulControl<Screen> } const ControlsContext = createContext<ControlsContext | null>(null) @@ -33,14 +35,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const mutedWordsDialogControl = Dialog.useDialogControl() const signinDialogControl = Dialog.useDialogControl() const inAppBrowserConsentControl = useStatefulDialogControl<string>() + const emailDialogControl = useStatefulDialogControl<Screen>() const ctx = useMemo<ControlsContext>( () => ({ mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl, + emailDialogControl, }), - [mutedWordsDialogControl, signinDialogControl, inAppBrowserConsentControl], + [ + mutedWordsDialogControl, + signinDialogControl, + inAppBrowserConsentControl, + emailDialogControl, + ], ) return ( @@ -48,7 +57,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) } -function useStatefulDialogControl<T>(initialValue?: T): StatefulControl<T> { +export function useStatefulDialogControl<T>( + initialValue?: T, +): StatefulControl<T> { const [value, setValue] = useState(initialValue) const control = Dialog.useDialogControl() return useMemo( diff --git a/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx new file mode 100644 index 000000000..0fc9a5469 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx @@ -0,0 +1,56 @@ +import {useState} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {wait} from '#/lib/async/wait' +import {atoms as a, type TextStyleProp, useTheme} from '#/alf' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Span, Text} from '#/components/Typography' + +export function ResendEmailText({ + onPress, + style, +}: TextStyleProp & { + onPress: () => Promise<any> +}) { + const t = useTheme() + const {_} = useLingui() + const [status, setStatus] = useState<'sending' | 'success' | null>(null) + + const handleOnPress = async () => { + setStatus('sending') + try { + await wait(1000, onPress()) + setStatus('success') + } finally { + setTimeout(() => { + setStatus(null) + }, 1000) + } + } + + return ( + <Text + style={[a.italic, a.leading_snug, t.atoms.text_contrast_medium, style]}> + <Trans> + Don't see an email?{' '} + <InlineLinkText + label={_(msg`Resend`)} + {...createStaticClick(() => { + handleOnPress() + })}> + Click here to resend. + </InlineLinkText> + </Trans>{' '} + <Span style={{top: 1}}> + {status === 'sending' ? ( + <Loader size="xs" /> + ) : status === 'success' ? ( + <Check size="xs" fill={t.palette.positive_500} /> + ) : null} + </Span> + </Text> + ) +} diff --git a/src/components/dialogs/EmailDialog/components/TokenField.tsx b/src/components/dialogs/EmailDialog/components/TokenField.tsx new file mode 100644 index 000000000..26a51e485 --- /dev/null +++ b/src/components/dialogs/EmailDialog/components/TokenField.tsx @@ -0,0 +1,45 @@ +import {type TextInputProps, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as TextField from '#/components/forms/TextField' +import {Shield_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' + +export function normalizeCode(value: string) { + const normalized = value.toUpperCase().replace(/[^A-Z2-7]/g, '') + if (normalized.length <= 5) return normalized + return `${normalized.slice(0, 5)}-${normalized.slice(5)}` +} + +export function isValidCode(value?: string) { + return Boolean(value && /^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(value)) +} + +export function TokenField({ + value, + onChangeText, + onSubmitEditing, +}: Pick<TextInputProps, 'value' | 'onChangeText' | 'onSubmitEditing'>) { + const {_} = useLingui() + const isInvalid = Boolean(value && value.length > 10 && !isValidCode(value)) + + const handleOnChangeText = (v: string) => { + onChangeText?.(normalizeCode(v)) + } + + return ( + <View> + <TextField.Root> + <TextField.Icon icon={Shield} /> + <TextField.Input + isInvalid={isInvalid} + label={_(msg`Confirmation code`)} + placeholder="XXXXX-XXXXX" + value={value} + onChangeText={handleOnChangeText} + onSubmitEditing={onSubmitEditing} + /> + </TextField.Root> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts new file mode 100644 index 000000000..377411107 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useAccountEmailState.ts @@ -0,0 +1,79 @@ +import {useCallback, useEffect, useState} from 'react' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events' + +export type AccountEmailState = { + isEmailVerified: boolean + email2FAEnabled: boolean +} + +export const accountEmailStateQueryKey = ['accountEmailState'] as const + +export function useInvalidateAccountEmailState() { + const qc = useQueryClient() + + return useCallback(() => { + return qc.invalidateQueries({ + queryKey: accountEmailStateQueryKey, + }) + }, [qc]) +} + +export function useUpdateAccountEmailStateQueryCache() { + const qc = useQueryClient() + + return useCallback( + (data: AccountEmailState) => { + return qc.setQueriesData( + { + queryKey: accountEmailStateQueryKey, + }, + data, + ) + }, + [qc], + ) +} + +export function useAccountEmailState() { + const agent = useAgent() + const [prevIsEmailVerified, setPrevEmailIsVerified] = useState( + !!agent.session?.emailConfirmed, + ) + const fallbackData: AccountEmailState = { + isEmailVerified: !!agent.session?.emailConfirmed, + email2FAEnabled: !!agent.session?.emailAuthFactor, + } + const query = useQuery<AccountEmailState>({ + enabled: !!agent.session, + refetchOnWindowFocus: true, + queryKey: accountEmailStateQueryKey, + queryFn: async () => { + // will also trigger updates to `#/state/session` data + const {data} = await agent.resumeSession(agent.session!) + return { + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + } + }, + }) + + const state = query.data ?? fallbackData + + /* + * This will emit `n` times for each instance of this hook. So the listeners + * all use `once` to prevent multiple handlers firing. + */ + useEffect(() => { + if (state.isEmailVerified && !prevIsEmailVerified) { + setPrevEmailIsVerified(true) + emitEmailVerified() + } else if (!state.isEmailVerified && prevIsEmailVerified) { + setPrevEmailIsVerified(false) + } + }, [state, prevIsEmailVerified]) + + return state +} diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts new file mode 100644 index 000000000..73f824fcc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -0,0 +1,29 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' + +export function useConfirmEmail() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateAccountEmailStateQueryCache = + useUpdateAccountEmailStateQueryCache() + + return useMutation({ + mutationFn: async ({token}: {token: string}) => { + if (!currentAccount?.email) { + throw new Error('No email found for the current account') + } + + await agent.com.atproto.server.confirmEmail({ + email: currentAccount.email, + token: token.trim(), + }) + const {data} = await agent.resumeSession(agent.session!) + updateAccountEmailStateQueryCache({ + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + }) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts new file mode 100644 index 000000000..39f5fd2d9 --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -0,0 +1,35 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import {useUpdateAccountEmailStateQueryCache} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' + +export function useManageEmail2FA() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateAccountEmailStateQueryCache = + useUpdateAccountEmailStateQueryCache() + + 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, + }) + const {data} = await agent.resumeSession(agent.session!) + updateAccountEmailStateQueryCache({ + isEmailVerified: !!data.emailConfirmed, + email2FAEnabled: !!data.emailAuthFactor, + }) + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts new file mode 100644 index 000000000..a442662fc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailUpdate() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + return (await agent.com.atproto.server.requestEmailUpdate()).data + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts new file mode 100644 index 000000000..ae308c7af --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts @@ -0,0 +1,13 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +export function useRequestEmailVerification() { + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + await agent.com.atproto.server.requestEmailConfirmation() + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts new file mode 100644 index 000000000..2ec1eb6dc --- /dev/null +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -0,0 +1,45 @@ +import {useMutation} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' + +async function updateEmailAndRefreshSession( + agent: ReturnType<typeof useAgent>, + email: string, + token?: string, +) { + await agent.com.atproto.server.updateEmail({email: email.trim(), token}) + await agent.resumeSession(agent.session!) +} + +export function useUpdateEmail() { + const agent = useAgent() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + + return useMutation< + {status: 'tokenRequired' | 'success'}, + Error, + {email: string; token?: string} + >({ + mutationFn: async ({email, token}: {email: string; token?: string}) => { + if (token) { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } else { + const {tokenRequired} = await requestEmailUpdate() + if (tokenRequired) { + return { + status: 'tokenRequired', + } + } else { + await updateEmailAndRefreshSession(agent, email, token) + return { + status: 'success', + } + } + } + }, + }) +} diff --git a/src/components/dialogs/EmailDialog/events.ts b/src/components/dialogs/EmailDialog/events.ts new file mode 100644 index 000000000..4fa171cad --- /dev/null +++ b/src/components/dialogs/EmailDialog/events.ts @@ -0,0 +1,23 @@ +import {useEffect} from 'react' +import EventEmitter from 'eventemitter3' + +const events = new EventEmitter<{ + emailVerified: void +}>() + +export function emitEmailVerified() { + events.emit('emailVerified') +} + +export function useOnEmailVerified(cb: () => void) { + useEffect(() => { + /* + * N.B. Use `once` here, since the event can fire multiple times for each + * instance of `useAccountEmailState` + */ + events.once('emailVerified', cb) + return () => { + events.off('emailVerified', cb) + } + }, [cb]) +} diff --git a/src/components/dialogs/EmailDialog/index.tsx b/src/components/dialogs/EmailDialog/index.tsx new file mode 100644 index 000000000..4bac7295a --- /dev/null +++ b/src/components/dialogs/EmailDialog/index.tsx @@ -0,0 +1,71 @@ +import {useCallback, useState} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import * as Dialog from '#/components/Dialog' +import {type StatefulControl} from '#/components/dialogs/Context' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' +import {Manage2FA} from '#/components/dialogs/EmailDialog/screens/Manage2FA' +import {Update} from '#/components/dialogs/EmailDialog/screens/Update' +import {VerificationReminder} from '#/components/dialogs/EmailDialog/screens/VerificationReminder' +import {Verify} from '#/components/dialogs/EmailDialog/screens/Verify' +import {type Screen, ScreenID} from '#/components/dialogs/EmailDialog/types' + +export type {Screen} from '#/components/dialogs/EmailDialog/types' +export {ScreenID as EmailDialogScreenID} from '#/components/dialogs/EmailDialog/types' + +export function useEmailDialogControl() { + return useGlobalDialogsControlContext().emailDialogControl +} + +export function EmailDialog() { + const {_} = useLingui() + const emailDialogControl = useEmailDialogControl() + const {isEmailVerified} = useAccountEmailState() + const onClose = useCallback(() => { + if (!isEmailVerified) { + if (emailDialogControl.value?.id === ScreenID.Verify) { + emailDialogControl.value.onCloseWithoutVerifying?.() + } + } + emailDialogControl.clear() + }, [isEmailVerified, emailDialogControl]) + + return ( + <Dialog.Outer control={emailDialogControl.control} onClose={onClose}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_(msg`Make adjustments to email settings for your account`)} + style={[{maxWidth: 400}]}> + <Inner control={emailDialogControl} /> + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function Inner({control}: {control: StatefulControl<Screen>}) { + const [screen, showScreen] = useState(() => control.value) + + if (!screen) return null + + switch (screen.id) { + case ScreenID.Update: { + return <Update config={screen} showScreen={showScreen} /> + } + case ScreenID.Verify: { + return <Verify config={screen} showScreen={showScreen} /> + } + case ScreenID.VerificationReminder: { + return <VerificationReminder config={screen} showScreen={showScreen} /> + } + case ScreenID.Manage2FA: { + return <Manage2FA config={screen} showScreen={showScreen} /> + } + 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 000000000..1896ff27d --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx @@ -0,0 +1,254 @@ +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 {useCleanError} from '#/lib/hooks/useCleanError' +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 { + isValidCode, + 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 + step: 'email' | 'token' + emailStatus: 'pending' | 'success' | 'error' | 'default' + tokenStatus: 'pending' | 'success' | 'error' | 'default' +} + +type Action = + | { + type: 'setError' + error: string + } + | { + type: 'setStep' + step: 'email' | 'token' + } + | { + type: 'setEmailStatus' + status: State['emailStatus'] + } + | { + type: 'setTokenStatus' + status: State['tokenStatus'] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setError': { + return { + ...state, + error: action.error, + emailStatus: 'error', + tokenStatus: 'error', + } + } + case 'setStep': { + return { + ...state, + error: '', + step: action.step, + } + } + 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 cleanError = useCleanError() + const {currentAccount} = useSession() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() + const control = useDialogContext() + + const [token, setToken] = useState('') + const [state, dispatch] = useReducer(reducer, { + error: '', + step: 'email', + emailStatus: 'default', + tokenStatus: 'default', + }) + + const handleSendEmail = async () => { + dispatch({type: 'setEmailStatus', status: 'pending'}) + try { + await wait(1000, requestEmailUpdate()) + dispatch({type: 'setEmailStatus', status: 'success'}) + setTimeout(() => { + dispatch({type: 'setStep', step: 'token'}) + }, 1000) + } catch (e) { + logger.error('Manage2FA: email update code request failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to send email, please try again.`), + }) + } + } + + const handleManageEmail2FA = async () => { + if (!isValidCode(token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({type: 'setTokenStatus', status: 'pending'}) + + try { + await wait(1000, manageEmail2FA({enabled: false, token})) + dispatch({type: 'setTokenStatus', status: 'success'}) + setTimeout(() => { + control.close() + }, 1000) + } catch (e) { + logger.error('Manage2FA: disable email 2FA failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email 2FA settings`), + }) + } + } + + return ( + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy, a.leading_snug]}> + <Trans>Disable email 2FA</Trans> + </Text> + + {state.step === '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({type: 'setStep', step: '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={handleManageEmail2FA} + /> + <ResendEmailText onPress={handleSendEmail} /> + </View> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Disable 2FA`)} + size="large" + variant="solid" + color="primary" + onPress={handleManageEmail2FA} + disabled={ + !token || token.length !== 11 || state.tokenStatus === 'pending' + }> + <ButtonText> + <Trans>Disable 2FA</Trans> + </ButtonText> + {state.tokenStatus === 'pending' ? ( + <ButtonIcon icon={Loader} /> + ) : state.tokenStatus === 'success' ? ( + <ButtonIcon icon={Check} /> + ) : null} + </Button> + </> + )} + </View> + ) +} 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 000000000..7a126792a --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx @@ -0,0 +1,137 @@ +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 {useCleanError} from '#/lib/hooks/useCleanError' +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 = + | { + type: 'setError' + error: string + } + | { + type: 'setStatus' + status: State['status'] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + 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 cleanError = useCleanError() + const {gtPhone} = useBreakpoints() + const {mutateAsync: manageEmail2FA} = useManageEmail2FA() + const control = useDialogContext() + + const [state, dispatch] = useReducer(reducer, { + error: '', + status: 'default', + }) + + const handleManageEmail2FA = async () => { + dispatch({type: 'setStatus', status: 'pending'}) + + try { + await wait(1000, manageEmail2FA({enabled: true})) + dispatch({type: 'setStatus', status: 'success'}) + setTimeout(() => { + control.close() + }, 1000) + } catch (e) { + logger.error('Manage2FA: enable email 2FA failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email 2FA settings`), + }) + } + } + + 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`Enable`)} + size="large" + variant="solid" + color="primary" + onPress={handleManageEmail2FA} + disabled={state.status === 'pending'}> + <ButtonText> + <Trans>Enable</Trans> + </ButtonText> + <ButtonIcon + position="right" + icon={ + state.status === 'pending' + ? Loader + : state.status === 'success' + ? Check + : ShieldIcon + } + /> + </Button> + <Button + label={_(msg`Cancel`)} + size="large" + variant="solid" + color="secondary" + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} 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 000000000..427a42b1f --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx @@ -0,0 +1,70 @@ +import {useEffect, useState} from 'react' +import {Trans} from '@lingui/macro' + +import {useAccountEmailState} from '#/components/dialogs/EmailDialog/data/useAccountEmailState' +import {Disable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Disable' +import {Enable} from '#/components/dialogs/EmailDialog/screens/Manage2FA/Enable' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' + +export function Manage2FA({showScreen}: ScreenProps<ScreenID.Manage2FA>) { + const {isEmailVerified, email2FAEnabled} = useAccountEmailState() + const [requestedAction, setRequestedAction] = useState< + 'enable' | 'disable' | null + >(null) + + useEffect(() => { + 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, + }) + }, + }) + } + }, [isEmailVerified, showScreen]) + + /* + * Wacky state handling so that once 2FA settings change, we don't show the + * wrong step of this form - esb + */ + + if (email2FAEnabled) { + if (!requestedAction) { + setRequestedAction('disable') + return <Disable /> + } + + if (requestedAction === 'disable') { + return <Disable /> + } + if (requestedAction === 'enable') { + return <Enable /> + } + } else { + if (!requestedAction) { + setRequestedAction('enable') + return <Enable /> + } + + if (requestedAction === 'disable') { + return <Disable /> + } + if (requestedAction === 'enable') { + return <Enable /> + } + } + + // should never happen + return null +} diff --git a/src/components/dialogs/EmailDialog/screens/Update.tsx b/src/components/dialogs/EmailDialog/screens/Update.tsx new file mode 100644 index 000000000..be0af8807 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Update.tsx @@ -0,0 +1,319 @@ +import {useReducer} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {validate as validateEmail} from 'email-validator' + +import {wait} from '#/lib/async/wait' +import {useCleanError} from '#/lib/hooks/useCleanError' +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 {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {useUpdateEmail} from '#/components/dialogs/EmailDialog/data/useUpdateEmail' +import { + type ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import * as TextField from '#/components/forms/TextField' +import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type State = { + step: 'email' | 'token' + mutationStatus: 'pending' | 'success' | 'error' | 'default' + error: string + emailValid: boolean + email: string + token: string +} + +type Action = + | { + type: 'setStep' + step: State['step'] + } + | { + type: 'setError' + error: string + } + | { + type: 'setMutationStatus' + status: State['mutationStatus'] + } + | { + type: 'setEmail' + value: string + } + | { + type: 'setToken' + value: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setStep': { + return { + ...state, + step: action.step, + } + } + case 'setError': { + return { + ...state, + error: action.error, + mutationStatus: 'error', + } + } + case 'setMutationStatus': { + return { + ...state, + error: '', + mutationStatus: action.status, + } + } + case 'setEmail': { + const emailValid = validateEmail(action.value) + return { + ...state, + step: 'email', + token: '', + email: action.value, + emailValid, + } + } + case 'setToken': { + return { + ...state, + error: '', + token: action.value, + } + } + } +} + +export function Update(_props: ScreenProps<ScreenID.Update>) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const [state, dispatch] = useReducer(reducer, { + step: 'email', + mutationStatus: 'default', + error: '', + email: '', + emailValid: true, + token: '', + }) + + const {mutateAsync: updateEmail} = useUpdateEmail() + const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + + const handleEmailChange = (email: string) => { + dispatch({ + type: 'setEmail', + value: email, + }) + } + + const handleUpdateEmail = async () => { + if (state.step === 'token' && !isValidCode(state.token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + if (state.emailValid === false) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid email address.`), + }) + return + } + + if (state.email === currentAccount!.email) { + dispatch({ + type: 'setError', + error: _(msg`This email is already associated with your account.`), + }) + return + } + + try { + const {status} = await wait( + 1000, + updateEmail({ + email: state.email, + token: state.token, + }), + ) + + if (status === 'tokenRequired') { + dispatch({ + type: 'setStep', + step: 'token', + }) + dispatch({ + type: 'setMutationStatus', + status: 'default', + }) + } else if (status === 'success') { + dispatch({ + type: 'setMutationStatus', + status: 'success', + }) + + try { + // fire off a confirmation email immediately + await requestEmailVerification() + } catch {} + } + } catch (e) { + logger.error('EmailDialog: update email failed', {safeMessage: e}) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to update email, please try again.`), + }) + } + } + + return ( + <View style={[a.gap_lg]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Update your email</Trans> + </Text> + + {currentAccount?.emailAuthFactor && ( + <Admonition type="warning"> + <Trans> + If you update your email address, email 2FA will be disabled. + </Trans> + </Admonition> + )} + + <View style={[a.gap_md]}> + <View> + <Text style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>Please enter your new email address.</Trans> + </Text> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + label={_(msg`New email address`)} + placeholder={_(msg`alice@example.com`)} + defaultValue={state.email} + onChangeText={ + state.mutationStatus === 'success' + ? undefined + : handleEmailChange + } + keyboardType="email-address" + autoComplete="email" + autoCapitalize="none" + onSubmitEditing={handleUpdateEmail} + /> + </TextField.Root> + </View> + + {state.step === 'token' && ( + <> + <Divider /> + <View> + <Text style={[a.text_md, a.pb_sm, a.font_bold]}> + <Trans>Security step required</Trans> + </Text> + <Text + style={[a.pb_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Please enter the security code we sent to your previous email + address. + </Trans> + </Text> + <TokenField + value={state.token} + onChangeText={ + state.mutationStatus === 'success' + ? undefined + : token => { + dispatch({ + type: 'setToken', + value: token, + }) + } + } + onSubmitEditing={handleUpdateEmail} + /> + {state.mutationStatus !== 'success' && ( + <ResendEmailText + onPress={requestEmailUpdate} + style={[a.pt_sm]} + /> + )} + </View> + </> + )} + + {state.error && <Admonition type="error">{state.error}</Admonition>} + </View> + + {state.mutationStatus === 'success' ? ( + <> + <Divider /> + <View style={[a.gap_sm]}> + <View style={[a.flex_row, a.gap_sm, a.align_center]}> + <Check fill={t.palette.positive_600} size="xs" /> + <Text style={[a.text_md, a.font_heavy]}> + <Trans>Success!</Trans> + </Text> + </View> + <Text style={[a.leading_snug]}> + <Trans> + Please click on the link in the email we just sent you to verify + your new email address. This is an important step to allow you + to continue enjoying all the features of Bluesky. + </Trans> + </Text> + </View> + </> + ) : ( + <Button + label={_(msg`Update email`)} + size="large" + variant="solid" + color="primary" + onPress={handleUpdateEmail} + disabled={ + !state.email || + (state.step === 'token' && + (!state.token || state.token.length !== 11)) || + state.mutationStatus === 'pending' + }> + <ButtonText> + <Trans>Update email</Trans> + </ButtonText> + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} + </Button> + )} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx new file mode 100644 index 000000000..267b784b0 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx @@ -0,0 +1,99 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import {GradientFill} from '#/components/GradientFill' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' +import {Text} from '#/components/Typography' + +export function VerificationReminder({ + showScreen, +}: ScreenProps<ScreenID.VerificationReminder>) { + const t = useTheme() + const {_} = useLingui() + const {gtPhone, gtMobile} = useBreakpoints() + const control = useDialogContext() + + const dialogPadding = gtMobile ? a.p_2xl.padding : a.p_xl.padding + + return ( + <View style={[a.gap_lg]}> + <View + style={[ + a.absolute, + { + top: platform({web: dialogPadding, default: a.p_2xl.padding}) * -1, + left: dialogPadding * -1, + right: dialogPadding * -1, + height: 150, + }, + ]}> + <View + style={[ + a.absolute, + a.inset_0, + a.align_center, + a.justify_center, + a.overflow_hidden, + a.pt_md, + t.atoms.bg_contrast_100, + ]}> + <GradientFill gradient={tokens.gradients.primary} /> + <ShieldIcon width={64} fill="white" style={[a.z_10]} /> + </View> + </View> + + <View style={[a.mb_xs, {height: 150 - dialogPadding}]} /> + + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Please verify your email</Trans> + </Text> + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Your email has not yet been verified. Please verify your email in + order to enjoy all the features of Bluesky. + </Trans> + </Text> + </View> + + <Divider /> + + <View style={[a.gap_sm, gtPhone && [a.flex_row_reverse]]}> + <Button + label={_(msg`Get started`)} + variant="solid" + color="primary" + size="large" + onPress={() => + showScreen({ + id: ScreenID.Verify, + }) + }> + <ButtonText> + <Trans>Get started</Trans> + </ButtonText> + </Button> + <Button + label={_(msg`Maybe later`)} + accessibilityHint={_(msg`Snoozes the reminder`)} + variant="ghost" + color="secondary" + size="large" + onPress={() => control.close()}> + <ButtonText> + <Trans>Maybe later</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx new file mode 100644 index 000000000..dabd0d2f2 --- /dev/null +++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx @@ -0,0 +1,386 @@ +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 {useCleanError} from '#/lib/hooks/useCleanError' +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 {ResendEmailText} from '#/components/dialogs/EmailDialog/components/ResendEmailText' +import { + isValidCode, + TokenField, +} from '#/components/dialogs/EmailDialog/components/TokenField' +import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail' +import {useRequestEmailVerification} from '#/components/dialogs/EmailDialog/data/useRequestEmailVerification' +import {useOnEmailVerified} from '#/components/dialogs/EmailDialog/events' +import { + ScreenID, + type ScreenProps, +} from '#/components/dialogs/EmailDialog/types' +import {Divider} from '#/components/Divider' +import {CheckThick_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 = { + step: 'email' | 'token' | 'success' + mutationStatus: 'pending' | 'success' | 'error' | 'default' + error: string + token: string +} + +type Action = + | { + type: 'setStep' + step: State['step'] + } + | { + type: 'setError' + error: string + } + | { + type: 'setMutationStatus' + status: State['mutationStatus'] + } + | { + type: 'setToken' + value: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setStep': { + return { + ...state, + error: '', + mutationStatus: 'default', + step: action.step, + } + } + case 'setError': { + return { + ...state, + error: action.error, + mutationStatus: 'error', + } + } + case 'setMutationStatus': { + return { + ...state, + error: '', + mutationStatus: action.status, + } + } + case 'setToken': { + return { + ...state, + error: '', + token: action.value, + } + } + } +} + +export function Verify({config, showScreen}: ScreenProps<ScreenID.Verify>) { + const t = useTheme() + const {_} = useLingui() + const cleanError = useCleanError() + const {currentAccount} = useSession() + const [state, dispatch] = useReducer(reducer, { + step: 'email', + mutationStatus: 'default', + error: '', + token: '', + }) + + const {mutateAsync: requestEmailVerification} = useRequestEmailVerification() + const {mutateAsync: confirmEmail} = useConfirmEmail() + + useOnEmailVerified(() => { + if (config.onVerify) { + config.onVerify() + } else { + dispatch({ + type: 'setStep', + step: 'success', + }) + } + }) + + const handleRequestEmailVerification = async () => { + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + try { + await wait(1000, requestEmailVerification()) + dispatch({ + type: 'setMutationStatus', + status: 'success', + }) + } catch (e) { + logger.error('EmailDialog: sending verification email failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to send email, please try again.`), + }) + } + } + + const handleConfirmEmail = async () => { + if (!isValidCode(state.token)) { + dispatch({ + type: 'setError', + error: _(msg`Please enter a valid code.`), + }) + return + } + + dispatch({ + type: 'setMutationStatus', + status: 'pending', + }) + + try { + await wait(1000, confirmEmail({token: state.token})) + dispatch({ + type: 'setStep', + step: 'success', + }) + } catch (e) { + logger.error('EmailDialog: confirming email failed', { + safeMessage: e, + }) + const {clean} = cleanError(e) + dispatch({ + type: 'setError', + error: clean || _(msg`Failed to verify email, please try again.`), + }) + } + } + + if (state.step === 'success') { + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Span style={{top: 1}}> + <Check size="sm" fill={t.palette.positive_600} /> + </Span> + {' '} + <Trans>Email verification complete!</Trans> + </Text> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + You have successfully verified your email address. You can close + this dialog. + </Trans> + </Text> + </View> + </View> + ) + } + + return ( + <View style={[a.gap_lg]}> + <View style={[a.gap_sm]}> + <Text style={[a.text_xl, a.font_heavy]}> + {state.step === 'email' ? ( + state.mutationStatus === 'success' ? ( + <> + <Span style={{top: 1}}> + <Check size="sm" fill={t.palette.positive_600} /> + </Span> + {' '} + <Trans>Email sent!</Trans> + </> + ) : ( + <Trans>Verify your email</Trans> + ) + ) : ( + <Trans>Verify email code</Trans> + )} + </Text> + + {state.step === 'email' && state.mutationStatus !== 'success' && ( + <> + {config.instructions?.map((int, i) => ( + <Text + key={i} + style={[ + a.italic, + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {int} + </Text> + ))} + </> + )} + + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + {state.step === 'email' ? ( + state.mutationStatus === 'success' ? ( + <Trans> + We sent an email to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + containing a link. Please click on it to complete the email + verification process. + </Trans> + ) : ( + <Trans> + We'll send an email to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + containing a link. Please click on it to complete the email + verification process. + </Trans> + ) + ) : ( + <Trans> + Please enter the code we sent to{' '} + <Span style={[a.font_bold, t.atoms.text]}> + {currentAccount!.email} + </Span>{' '} + below. + </Trans> + )} + </Text> + + {state.step === 'email' && state.mutationStatus !== 'success' && ( + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + If you need to update your email,{' '} + <InlineLinkText + label={_(msg`Click here to update your email`)} + {...createStaticClick(() => { + showScreen({id: ScreenID.Update}) + })}> + click here + </InlineLinkText> + . + </Trans> + </Text> + )} + + {state.step === 'email' && state.mutationStatus === 'success' && ( + <ResendEmailText onPress={requestEmailVerification} /> + )} + </View> + + {state.step === 'email' && state.mutationStatus !== 'success' ? ( + <> + {state.error && <Admonition type="error">{state.error}</Admonition>} + <Button + label={_(msg`Send verification email`)} + size="large" + variant="solid" + color="primary" + onPress={handleRequestEmailVerification} + disabled={state.mutationStatus === 'pending'}> + <ButtonText> + <Trans>Send email</Trans> + </ButtonText> + <ButtonIcon + icon={state.mutationStatus === 'pending' ? Loader : Envelope} + /> + </Button> + </> + ) : null} + + {state.step === 'email' && ( + <> + <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({ + type: 'setStep', + step: 'token', + }) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </> + )} + + {state.step === 'token' ? ( + <> + <TokenField + value={state.token} + onChangeText={token => { + dispatch({ + type: 'setToken', + value: token, + }) + }} + onSubmitEditing={handleConfirmEmail} + /> + + {state.error && <Admonition type="error">{state.error}</Admonition>} + + <Button + label={_(msg`Verify code`)} + size="large" + variant="solid" + color="primary" + onPress={handleConfirmEmail} + disabled={ + !state.token || + state.token.length !== 11 || + state.mutationStatus === 'pending' + }> + <ButtonText> + <Trans>Verify code</Trans> + </ButtonText> + {state.mutationStatus === 'pending' && <ButtonIcon icon={Loader} />} + </Button> + + <Divider /> + + <Text + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Don't have a code or need a new one?{' '} + <InlineLinkText + label={_(msg`Click here to restart the verification process.`)} + {...createStaticClick(() => { + dispatch({ + type: 'setStep', + step: 'email', + }) + })}> + Click here. + </InlineLinkText> + </Trans> + </Text> + </> + ) : null} + </View> + ) +} diff --git a/src/components/dialogs/EmailDialog/types.ts b/src/components/dialogs/EmailDialog/types.ts new file mode 100644 index 000000000..7edc3facc --- /dev/null +++ b/src/components/dialogs/EmailDialog/types.ts @@ -0,0 +1,38 @@ +import {type ReactNode} from 'react' + +import {type DialogControlProps} from '#/components/Dialog' + +export type EmailDialogProps = { + control: DialogControlProps +} + +export type EmailDialogInnerProps = EmailDialogProps & {} + +export type Screen = + | { + id: ScreenID.Update + } + | { + id: ScreenID.Verify + instructions?: ReactNode[] + onVerify?: () => void + onCloseWithoutVerifying?: () => void + } + | { + id: ScreenID.VerificationReminder + } + | { + id: ScreenID.Manage2FA + } + +export enum ScreenID { + Update = 'Update', + Verify = 'Verify', + VerificationReminder = 'VerificationReminder', + Manage2FA = 'Manage2FA', +} + +export type ScreenProps<T extends ScreenID> = { + config: Extract<Screen, {id: T}> + showScreen: (screen: Screen) => void +} |