mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: show the two-factor code prompt as a hint, not a red error (bulwarkmail/webmail#1422)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user