From b205c13a6e992cc77b219ae5189798a47dcc109c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:18:10 +0100 Subject: [PATCH] fix: show the two-factor code prompt as a hint, not a red error (bulwarkmail/webmail#1422) --- src/lib/__tests__/login-errors.test.ts | 8 ++++++++ src/lib/login-errors.ts | 7 +++++++ src/screens/login/LoginNotice.tsx | 15 +++++++++++++-- src/screens/login/PasswordStep.tsx | 4 ++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts index 0059d62d..287849e1 100644 --- a/src/lib/__tests__/login-errors.test.ts +++ b/src/lib/__tests__/login-errors.test.ts @@ -16,6 +16,14 @@ describe('describeLoginError', () => { expect(copy.detail).toMatch(/app password/i); }); + it('shows the two-factor prompt as a next step, not as an error (bulwarkmail/webmail#1422)', () => { + expect(describeLoginError(named('TotpRequiredError', 'MFA code required')).tone).toBe('info'); + expect(describeLoginError(named('Error', 'Session discovery failed: 402 Payment Required')).tone).toBe('info'); + // A wrong code still is one. + expect(describeLoginError(named('TotpLoginError', 'Invalid username, password or code')).tone).not.toBe('info'); + expect(describeLoginError(named('AuthenticationError', 'Invalid credentials')).tone).not.toBe('info'); + }); + it('blames a refused access token on the token, not on a password', () => { for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) { const copy = describeLoginError(err); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index a1b18470..fb85c738 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -10,6 +10,11 @@ import { MAX_ACCOUNTS } from './account-utils'; export interface LoginErrorCopy { title: string; detail?: string; + /** + * `info` for a next step rather than a failure: the server asking for the + * two-factor code (webmail #1422). Absent means an error. + */ + tone?: 'error' | 'info'; } type Translate = (key: string, fallback?: string, params?: Record) => string; @@ -123,6 +128,7 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} return { title: t('login.mobile.err_totp_title', 'Enter your two-factor code'), detail: t('login.mobile.err_totp_detail', 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.'), + tone: 'info', }; } @@ -157,6 +163,7 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} return { title: t('login.mobile.err_totp_title', 'Enter your two-factor code'), detail: t('login.mobile.err_totp_detail', 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.'), + tone: 'info', }; } diff --git a/src/screens/login/LoginNotice.tsx b/src/screens/login/LoginNotice.tsx index 05dc51f1..5b7acbe7 100644 --- a/src/screens/login/LoginNotice.tsx +++ b/src/screens/login/LoginNotice.tsx @@ -3,25 +3,36 @@ import { View, Text, StyleSheet } from 'react-native'; import { AlertTriangle, Lock } from '../../components/icons'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { announce } from '../../lib/announce'; interface LoginNoticeProps { title: string; detail?: string; tone?: 'error' | 'info'; + /** + * Read out when it appears, in either tone (a status, not a static hint): + * the code prompt is info now (webmail #1422) but still news. + */ + live?: boolean; } /** Inline banner for a sign-in failure, or a reassurance about where a * password ends up. Errors lead with what went wrong, then what to do. */ -export default function LoginNotice({ title, detail, tone = 'error' }: LoginNoticeProps) { +export default function LoginNotice({ title, detail, tone = 'error', live }: LoginNoticeProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const isError = tone === 'error'; const Icon = isError ? AlertTriangle : Lock; + const isLive = live ?? isError; + // iOS reads no live regions. + React.useEffect(() => { + if (isLive) announce([title, detail].filter(Boolean).join('. '), { assertive: isError }); + }, [isLive, isError, title, detail]); return ( diff --git a/src/screens/login/PasswordStep.tsx b/src/screens/login/PasswordStep.tsx index 3bd6b8fe..39cba046 100644 --- a/src/screens/login/PasswordStep.tsx +++ b/src/screens/login/PasswordStep.tsx @@ -18,7 +18,7 @@ interface PasswordStepProps { totpRequired?: boolean; onChangeTotp?: (value: string) => void; onSubmit: () => void; - notice?: { title: string; detail?: string } | null; + notice?: { title: string; detail?: string; tone?: 'error' | 'info' } | null; /** Access-token sign-in in place of username and password. */ tokenMode?: boolean; token?: string; @@ -148,7 +148,7 @@ export default function PasswordStep({ )} - {notice ? : null} + {notice ? : null}