[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>
This commit is contained in:
Eric Bailey
2025-05-07 10:23:33 -05:00
committed by GitHub
parent 0edd3bd3b4
commit 0f96669f8c
55 changed files with 2257 additions and 402 deletions
+13 -2
View File
@@ -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(
@@ -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>
)
}
@@ -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>
)
}
@@ -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
}
@@ -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,
})
},
})
}
@@ -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,
})
},
})
}
@@ -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
},
})
}
@@ -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()
},
})
}
@@ -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',
}
}
}
},
})
}
@@ -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])
}
@@ -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
}
}
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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
}