fix: show the two-factor code prompt as a hint, not a red error (bulwarkmail/webmail#1422)

This commit is contained in:
Linus Rath
2026-10-31 04:18:10 +01:00
parent 9bd4674c9b
commit b205c13a6e
4 changed files with 30 additions and 4 deletions
+8
View File
@@ -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);
+7
View File
@@ -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, string | number>) => 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',
};
}
+13 -2
View File
@@ -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 (
<View
style={[styles.banner, isError ? styles.bannerError : styles.bannerInfo]}
accessibilityLiveRegion={isError ? 'polite' : 'none'}
accessibilityLiveRegion={isLive ? 'polite' : 'none'}
>
<Icon size={16} color={isError ? c.error : c.textMuted} style={styles.icon} />
<View style={styles.text}>
+2 -2
View File
@@ -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 ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
{notice ? <LoginNotice title={notice.title} detail={notice.detail} tone={notice.tone} live /> : null}
<Button variant="default" size="md" onPress={onSubmit}>
{t('login.sign_in', 'Sign in')}