fix: show a translated notice when the sign-in session expired

The sign-in screen showed the auth store's raw English error ("Session
expired", "Invalid username or password") as the notice title. It now
runs it through describeLoginError like every other sign-in failure,
which also learns to explain an expired session with the webmail's
login.session_expired copy. The back and close buttons of the sign-in
steps get screen reader labels, and the email placeholders use the
webmail's translated example address.
This commit is contained in:
Linus Rath
2026-09-24 20:49:55 +02:00
parent 771558d8c7
commit 5801d6c97a
7 changed files with 42 additions and 5 deletions
@@ -17,6 +17,17 @@ const TRANSLATED_FILES: Record<string, string[]> = {
// The link prompt shows an example URL.
'screens/ComposeScreen.tsx': ['https://example.com'],
'components/RichTextEditor.tsx': [],
'screens/LoginScreen.tsx': [],
// Product name and example host.
'screens/login/ChooseStep.tsx': ['Bulwark Mail'],
'screens/login/ConfirmStep.tsx': [],
'screens/login/EmailStep.tsx': [],
'screens/login/LoginNotice.tsx': [],
'screens/login/LoginShell.tsx': ['Bulwark Mobile v'],
'screens/login/OptionTile.tsx': [],
'screens/login/PasswordStep.tsx': [],
'screens/login/ServerStep.tsx': ['mail.example.com'],
'screens/login/SigningInStep.tsx': [],
'screens/ScheduledScreen.tsx': [],
'screens/EmailSourceScreen.tsx': [],
'components/OfflineBanner.tsx': [],
+5
View File
@@ -14,6 +14,11 @@ describe('describeLoginError', () => {
expect(copy.detail).toMatch(/app password/i);
});
it('explains the auth store\'s "Session expired" notice', () => {
expect(describeLoginError('Session expired').title).toBe('Your session has expired. Please sign in again.');
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
});
it('names the host it could not reach', () => {
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
serverUrl: 'https://mail.example.com',
+6
View File
@@ -84,6 +84,12 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
};
}
// A saved session the server no longer accepts (the auth store's
// "Session expired" notice when the app lands back on sign-in).
if (lower.includes('session expired')) {
return { title: t('login.session_expired', 'Your session has expired. Please sign in again.') };
}
if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) {
return {
title: t('login.mobile.err_cert_title', "Couldn't verify {host}", { host }),
+2 -1
View File
@@ -333,7 +333,8 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
if (step === 'choose') {
// A leftover store error ("Session expired") is the reason some people
// land back here, so it belongs on this screen.
const chooseNotice = notice ?? (storeError ? { title: storeError } : null);
// The store keeps the raw English message; show the translated copy.
const chooseNotice = notice ?? (storeError ? describeLoginError(storeError, { t }) : null);
return (
<>
<LoginShell {...shellProps} centered showFooter>
+1 -1
View File
@@ -49,7 +49,7 @@ export default function EmailStep({
</View>
<Input
placeholder="you@example.com"
placeholder={t('login.username_placeholder', 'user@example.com')}
value={value}
onChangeText={onChange}
autoFocus
+16 -2
View File
@@ -13,6 +13,7 @@ import Constants from 'expo-constants';
import { ArrowLeft, X } from 'lucide-react-native';
import { spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -41,6 +42,7 @@ export default function LoginShell({
}: LoginShellProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<SafeAreaView style={styles.container}>
@@ -50,14 +52,26 @@ export default function LoginShell({
>
<View style={styles.header}>
{onBack ? (
<Pressable onPress={onBack} hitSlop={12} style={styles.headerButton}>
<Pressable
onPress={onBack}
hitSlop={12}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.textSecondary} />
</Pressable>
) : (
<View style={styles.headerButton} />
)}
{onClose ? (
<Pressable onPress={onClose} hitSlop={12} style={styles.headerButton}>
<Pressable
onPress={onClose}
hitSlop={12}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={22} color={c.textSecondary} />
</Pressable>
) : (
+1 -1
View File
@@ -55,7 +55,7 @@ export default function PasswordStep({
<Input
label={t('login.mobile.username_label', 'Email or username')}
placeholder="you@example.com"
placeholder={t('login.username_placeholder', 'user@example.com')}
value={email}
onChangeText={onChangeEmail}
autoCapitalize="none"