From 240471764a881a772a074b9077331e1d9915f02f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 29 Aug 2026 18:10:25 +0200
Subject: [PATCH] feat: localize the sign-in flow and offer previously used
addresses
---
locales/rn/en.json | 144 +++++++++++++++++++++++++++-
src/components/QrScanModal.tsx | 14 +--
src/lib/login-errors.ts | 73 +++++++-------
src/screens/LoginScreen.tsx | 71 ++++++++++----
src/screens/login/ChooseStep.tsx | 24 ++---
src/screens/login/ConfirmStep.tsx | 18 ++--
src/screens/login/EmailStep.tsx | 41 +++++++-
src/screens/login/PasswordStep.tsx | 18 ++--
src/screens/login/ServerStep.tsx | 16 ++--
src/screens/login/SigningInStep.tsx | 12 ++-
10 files changed, 327 insertions(+), 104 deletions(-)
diff --git a/locales/rn/en.json b/locales/rn/en.json
index 476abaf3..065ceb7d 100644
--- a/locales/rn/en.json
+++ b/locales/rn/en.json
@@ -2,11 +2,30 @@
"calendar": {
"event_modal": {
"participant_placeholder": "Add participant by email"
+ },
+ "management": {
+ "description_placeholder": "Optional",
+ "edit_calendar": "Edit calendar",
+ "new_calendar": "New calendar"
+ },
+ "share": {
+ "add_people": "Add people",
+ "error": "Failed to update sharing",
+ "no_matches": "No matches",
+ "no_principals": "Sharing is not available on this server.",
+ "role_custom": "Custom",
+ "search_placeholder": "Search by name or email",
+ "shared_with": "Shared with",
+ "title": "Share"
}
},
"common": {
+ "add": "Add",
"back": "Back",
- "dismiss": "Dismiss"
+ "create": "Create",
+ "dismiss": "Dismiss",
+ "done": "Done",
+ "remove": "Remove"
},
"context_menu": {
"color_tag": "Tag",
@@ -37,9 +56,14 @@
},
"email_list": {
"error": "Error",
+ "marked_as_spam": "Marked as spam",
+ "marked_not_spam": "Marked as not spam",
+ "move_same_account": "Messages can only be moved within the same account",
+ "moved_to_trash": "Email moved to Trash",
"no_archive_folder": "Could not find an Archive folder on the server.",
"no_junk_folder": "Could not find a Spam/Junk folder on the server.",
- "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration."
+ "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.",
+ "undo_failed": "Undo failed"
},
"email_viewer": {
"color_tag": {
@@ -69,6 +93,92 @@
"share_search_placeholder": "Search by name or email",
"shared_with": "Shared with"
},
+ "login": {
+ "mobile": {
+ "add_subtitle": "You're signed in to these already.",
+ "add_title": "Add another account",
+ "camera_allow": "Allow camera access",
+ "camera_disabled": "Camera access is disabled. Enable it in Settings to scan a sign-in QR code.",
+ "camera_needed": "Bulwark Mail needs camera access to scan a sign-in QR code.",
+ "choose_subtitle": "Choose how you'd like to sign in.",
+ "confirm_check": "Check this looks right",
+ "confirm_found": "Found your mail server",
+ "confirm_subtitle": "Next you'll sign in on your provider's own page.",
+ "connecting": "Connecting to {host}",
+ "connecting_detail": "Setting up your mailbox…",
+ "continue": "Continue",
+ "different_server": "Use a different server",
+ "elsewhere_title": "An account somewhere else",
+ "email_desc": "We'll find your mail server",
+ "email_step_hint": "Works with Stalwart and any other JMAP mail server.",
+ "email_step_subtitle": "That's usually all we need to find your mail server.",
+ "email_step_title": "What's your email address?",
+ "email_step_title_add": "What's the other address?",
+ "email_title": "Use my email address",
+ "err_bad_detail": "Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.",
+ "err_bad_title": "That didn't work",
+ "err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
+ "err_cert_title": "Couldn't verify {host}",
+ "err_code_expired_detail": "Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.",
+ "err_code_expired_title": "That code has expired",
+ "err_generic_detail": "Something went wrong. Try again.",
+ "err_generic_title": "Sign-in failed",
+ "err_interrupted_detail": "The response didn't match the request we started. Try signing in again.",
+ "err_interrupted_title": "Sign-in was interrupted",
+ "err_no_server_detail": "Double-check the address, or scan a sign-in code from the webmail instead.",
+ "err_no_server_title": "No mail server at {host}",
+ "err_no_webmail_detail": "This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.",
+ "err_no_webmail_title": "No sign-in page at {host}",
+ "err_rate_detail": "The server asked us to wait a moment. Try again shortly.",
+ "err_rate_detail_seconds": "The server asked us to wait. Try again in about {seconds} seconds.",
+ "err_rate_title": "Too many attempts",
+ "err_server_detail": "The server answered with an error. Try again in a few minutes.",
+ "err_server_title": "{host} is having trouble",
+ "err_totp_bad_detail": "Check your password and the current code from your authenticator app, then try again.",
+ "err_totp_detail": "This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.",
+ "err_totp_title": "Enter your two-factor code",
+ "err_unreachable_detail": "Check your connection and the server address, then try again.",
+ "err_unreachable_title": "Can't reach {host}",
+ "know_server": "I know my server address",
+ "known_server_desc": "We already know this server, so there's no setup",
+ "known_server_title": "Another account on {host}",
+ "looking_up": "Looking up your server…",
+ "manual": "Enter server details manually",
+ "notice_bad_qr": "That code isn't a Bulwark sign-in code",
+ "notice_bad_qr_detail": "Open Bulwark on the web, then Settings → Security → Link device to show one.",
+ "notice_bad_server": "That doesn't look like a server address",
+ "notice_bad_server_detail": "Try the address you use for webmail, like mail.example.com.",
+ "notice_full_email": "Enter a full email address",
+ "notice_full_email_detail": "Like ada@example.com. If you sign in with a username instead, use “I know my server address”.",
+ "notice_need_code": "Enter the 6-digit code from your authenticator app",
+ "notice_need_credentials": "Enter your email and password",
+ "notice_need_server": "We need a server address first",
+ "opening": "Opening {host}",
+ "opening_detail": "Finish signing in on the page that just opened. We’ll take it from there.",
+ "pairing": "Redeeming your sign-in code",
+ "pairing_detail": "Pairing this device with {host}…",
+ "password_subtitle": "Your password is sent to this server only, and stored in the device keychain.",
+ "password_title": "Sign in to {host}",
+ "provider_detail": "You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time.",
+ "provider_title": "Your password stays with your provider",
+ "qr_hint": "Settings → Security → Link device shows a code. Point the camera at it.",
+ "qr_hint_title": "Open Bulwark on your computer",
+ "qr_title": "Sign-in code",
+ "scan_desc": "The fastest way in if you're signed in on the web",
+ "scan_title": "Scan a sign-in code",
+ "server_hint": "Same address you type in the browser. We'll add https:// for you.",
+ "server_label": "Server address",
+ "server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.",
+ "server_scan_hint": "If you're not sure, scan a sign-in code instead. It carries the server address.",
+ "server_subtitle": "Enter the address you use to reach your webmail.",
+ "server_title": "What's your server address?",
+ "the_server": "the server",
+ "unencrypted": "Unencrypted connection",
+ "use_password": "Sign in with a password instead",
+ "username_label": "Email or username",
+ "your_server": "your server"
+ }
+ },
"offline": {
"banner": "You are offline",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect"
@@ -80,6 +190,16 @@
"title": "Get notified about new mail"
},
"settings": {
+ "account": {
+ "accounts": {
+ "description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
+ "limit": "Maximum of {count} accounts reached."
+ },
+ "shared_accounts": {
+ "description_mobile": "Group and shared accounts you can read through this account. Their filters and vacation responder are managed from the webmail."
+ },
+ "unknown": "Unknown"
+ },
"advanced": {
"about": {
"deprecated_update": "Update required: {version}",
@@ -163,6 +283,22 @@
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
},
+ "security": {
+ "encryption": {
+ "no_keys": "Add a public key first - encryption at rest needs one."
+ },
+ "not_supported": "Account security management requires a Stalwart server. These features are not available for this account.",
+ "offline": "You are offline. Account security settings need a live connection to the server.",
+ "public_keys": {
+ "add_error": "Failed to add public key",
+ "description": "S/MIME certificates and PGP keys stored on the server. They are used for encryption at rest.",
+ "expires": "expires {date}",
+ "remove_error": "Failed to remove public key",
+ "remove_message": "Encryption at rest that uses this key will stop working.",
+ "remove_title": "Remove public key?",
+ "title": "Public Keys"
+ }
+ },
"smime": {
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
@@ -176,6 +312,10 @@
}
}
},
+ "sidebar": {
+ "remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
+ "remove_account_title": "Remove account?"
+ },
"updates": {
"advisory": "Security advisory",
"advisory_desc": "Details about the issue this release fixes.",
diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx
index c7f052a7..e3279014 100644
--- a/src/components/QrScanModal.tsx
+++ b/src/components/QrScanModal.tsx
@@ -6,6 +6,7 @@ import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
+import { useLocaleStore } from '../stores/locale-store';
interface QrScanModalProps {
visible: boolean;
@@ -19,6 +20,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]);
+ const t = useLocaleStore((st) => st.t);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
@@ -61,9 +63,9 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
- Open Bulwark on your computer
+ {t('login.mobile.qr_hint_title', 'Open Bulwark on your computer')}
- Settings → Security → Link device shows a code. Point the camera at it.
+ {t('login.mobile.qr_hint', 'Settings → Security → Link device shows a code. Point the camera at it.')}
@@ -75,7 +77,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
- Sign-in code
+ {t('login.mobile.qr_title', 'Sign-in code')}
@@ -83,11 +85,11 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
{permission && !permission.canAskAgain
- ? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
- : 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
+ ? t('login.mobile.camera_disabled', 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.')
+ : t('login.mobile.camera_needed', 'Bulwark Mail needs camera access to scan a sign-in QR code.')}
) : null}
diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts
index b135546b..fd7e357f 100644
--- a/src/lib/login-errors.ts
+++ b/src/lib/login-errors.ts
@@ -11,9 +11,13 @@ export interface LoginErrorCopy {
detail?: string;
}
+type Translate = (key: string, fallback?: string, params?: Record) => string;
+
export interface LoginErrorContext {
/** Host shown in "can't reach X" copy. */
serverUrl?: string | null;
+ /** Translator; English copy is used when absent (tests, non-UI callers). */
+ t?: Translate;
}
function messageOf(err: unknown): string {
@@ -24,29 +28,34 @@ function nameOf(err: unknown): string {
return err instanceof Error ? err.name : '';
}
-function hostLabel(serverUrl: string | null | undefined): string {
- if (!serverUrl) return 'the server';
+function hostLabel(serverUrl: string | null | undefined, fallback: string): string {
+ if (!serverUrl) return fallback;
const host = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
- return host || 'the server';
+ return host || fallback;
}
export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
const name = nameOf(err);
const message = messageOf(err);
const lower = message.toLowerCase();
- const host = hostLabel(context.serverUrl);
+ const t: Translate = context.t ?? ((_key, fallback, params) => {
+ let out = fallback ?? _key;
+ for (const [k, v] of Object.entries(params ?? {})) out = out.replace(`{${k}}`, String(v));
+ return out;
+ });
+ const host = hostLabel(context.serverUrl, t('login.mobile.the_server', 'the server'));
if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) {
return {
- title: 'Enter your two-factor code',
- detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.',
+ 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.'),
};
}
if (name === 'TotpLoginError' && lower.includes('invalid')) {
return {
- title: "That didn't work",
- detail: 'Check your password and the current code from your authenticator app, then try again.',
+ title: t('login.mobile.err_bad_title', "That didn't work"),
+ detail: t('login.mobile.err_totp_bad_detail', 'Check your password and the current code from your authenticator app, then try again.'),
};
}
@@ -54,33 +63,31 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
const retryMs = (err as { retryAfterMs?: number } | null)?.retryAfterMs;
const seconds = typeof retryMs === 'number' && Number.isFinite(retryMs) ? Math.max(1, Math.round(retryMs / 1000)) : null;
return {
- title: 'Too many attempts',
+ title: t('login.mobile.err_rate_title', 'Too many attempts'),
detail: seconds
- ? `The server asked us to wait. Try again in about ${seconds} seconds.`
- : 'The server asked us to wait a moment. Try again shortly.',
+ ? t('login.mobile.err_rate_detail_seconds', 'The server asked us to wait. Try again in about {seconds} seconds.', { seconds })
+ : t('login.mobile.err_rate_detail', 'The server asked us to wait a moment. Try again shortly.'),
};
}
if (lower.includes('session discovery failed') && /\b402\b/.test(message)) {
return {
- title: 'Enter your two-factor code',
- detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.',
+ 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.'),
};
}
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
return {
- title: "That didn't work",
- detail:
- 'Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.',
+ title: t('login.mobile.err_bad_title', "That didn't work"),
+ detail: t('login.mobile.err_bad_detail', 'Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.'),
};
}
if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) {
return {
- title: `Couldn't verify ${host}`,
- detail:
- "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
+ title: t('login.mobile.err_cert_title', "Couldn't verify {host}", { host }),
+ detail: t('login.mobile.err_cert_detail', "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired."),
};
}
@@ -88,8 +95,8 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
// mistyped host or a webmail that lives on a subpath.
if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) {
return {
- title: `No mail server at ${host}`,
- detail: 'Double-check the address, or scan a sign-in code from the webmail instead.',
+ title: t('login.mobile.err_no_server_title', 'No mail server at {host}', { host }),
+ detail: t('login.mobile.err_no_server_detail', 'Double-check the address, or scan a sign-in code from the webmail instead.'),
};
}
@@ -103,36 +110,36 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
lower.includes('timed out')
) {
return {
- title: `Can't reach ${host}`,
- detail: 'Check your connection and the server address, then try again.',
+ title: t('login.mobile.err_unreachable_title', "Can't reach {host}", { host }),
+ detail: t('login.mobile.err_unreachable_detail', 'Check your connection and the server address, then try again.'),
};
}
if (lower.includes('session discovery failed') && /\b5\d\d\b/.test(message)) {
return {
- title: `${host} is having trouble`,
- detail: 'The server answered with an error. Try again in a few minutes.',
+ title: t('login.mobile.err_server_title', '{host} is having trouble', { host }),
+ detail: t('login.mobile.err_server_detail', 'The server answered with an error. Try again in a few minutes.'),
};
}
if (lower.includes('no bulwark webmail or sign-in service')) {
return {
- title: `No sign-in page at ${host}`,
- detail: 'This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.',
+ title: t('login.mobile.err_no_webmail_title', 'No sign-in page at {host}', { host }),
+ detail: t('login.mobile.err_no_webmail_detail', 'This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.'),
};
}
if (lower.includes('pairing code')) {
return {
- title: 'That code has expired',
- detail: 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.',
+ title: t('login.mobile.err_code_expired_title', 'That code has expired'),
+ detail: t('login.mobile.err_code_expired_detail', 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.'),
};
}
if (lower.includes('state mismatch')) {
return {
- title: 'Sign-in was interrupted',
- detail: "The response didn't match the request we started. Try signing in again.",
+ title: t('login.mobile.err_interrupted_title', 'Sign-in was interrupted'),
+ detail: t('login.mobile.err_interrupted_detail', "The response didn't match the request we started. Try signing in again."),
};
}
@@ -142,7 +149,7 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
// Unrecognised: show what we were told rather than inventing a cause.
return {
- title: 'Sign-in failed',
- detail: message || 'Something went wrong. Try again.',
+ title: t('login.mobile.err_generic_title', 'Sign-in failed'),
+ detail: message || t('login.mobile.err_generic_detail', 'Something went wrong. Try again.'),
};
}
diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx
index 42b2f980..c7f307dd 100644
--- a/src/screens/LoginScreen.tsx
+++ b/src/screens/LoginScreen.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { BackHandler } from 'react-native';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
+import { useLocaleStore } from '../stores/locale-store';
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import {
@@ -27,6 +29,8 @@ interface LoginScreenProps {
type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
+const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
+
/**
* Sign-in, one question per screen, cheapest question first.
*
@@ -43,6 +47,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const clearError = useAuthStore((state) => state.clearError);
const storeError = useAuthStore((state) => state.error);
+ const t = useLocaleStore((state) => state.t);
const accounts = useAccountStore((state) => state.accounts);
const activeAccountId = useAccountStore((state) => state.activeAccountId);
@@ -65,6 +70,32 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const [busy, setBusy] = React.useState(null);
const [scannerVisible, setScannerVisible] = React.useState(false);
+ // Addresses typed before on this device (last 5) plus the registry's, so a
+ // returning user taps instead of retyping. Never contains passwords.
+ const [recentEmails, setRecentEmails] = React.useState([]);
+ React.useEffect(() => {
+ void AsyncStorage.getItem(RECENT_EMAILS_KEY).then((raw) => {
+ try {
+ const list = raw ? (JSON.parse(raw) as unknown) : [];
+ if (Array.isArray(list)) setRecentEmails(list.filter((v): v is string => typeof v === 'string'));
+ } catch {
+ // ignore corrupt entry
+ }
+ }).catch(() => undefined);
+ }, []);
+ const emailSuggestions = React.useMemo(() => {
+ const out: string[] = [];
+ for (const v of [...recentEmails, ...accounts.map((a) => a.email || a.username)]) {
+ if (v && v.includes('@') && !out.includes(v)) out.push(v);
+ }
+ return out;
+ }, [recentEmails, accounts]);
+ const rememberEmail = React.useCallback((address: string) => {
+ const next = [address, ...recentEmails.filter((v) => v !== address)].slice(0, 5);
+ setRecentEmails(next);
+ void AsyncStorage.setItem(RECENT_EMAILS_KEY, JSON.stringify(next)).catch(() => undefined);
+ }, [recentEmails]);
+
// Servers we already hold credentials for. Discovery trusts these without a
// probe, which makes "second account on the same host" instant.
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]);
@@ -150,31 +181,32 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setServerUrl(pending.serverUrl);
setTotpRequired(true);
setBusy(null);
- setNotice(describeLoginError(err, { serverUrl: target }));
+ setNotice(describeLoginError(err, { serverUrl: target, t }));
setHistory((prev) => [...prev, step]);
setStep('password');
return;
}
- setNotice(describeLoginError(err, { serverUrl: target }));
+ setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
}
},
- [finishIfSignedIn, isAddMode, loginViaWebmail, step],
+ [finishIfSignedIn, isAddMode, loginViaWebmail, step, t],
);
const handleEmailContinue = React.useCallback(async () => {
const value = email.trim();
if (!isEmailAddress(value)) {
setNotice({
- title: 'Enter a full email address',
- detail: 'Like ada@example.com. If you sign in with a username instead, use “I know my server address”.',
+ title: t('login.mobile.notice_full_email', 'Enter a full email address'),
+ detail: t('login.mobile.notice_full_email_detail', 'Like ada@example.com. If you sign in with a username instead, use “I know my server address”.'),
});
return;
}
setNotice(null);
setSearching(true);
+ rememberEmail(value);
try {
const found = await discoverServerForEmail(value, { knownServerUrls });
if (found) {
@@ -190,35 +222,35 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
} finally {
setSearching(false);
}
- }, [email, goTo, knownServerUrls]);
+ }, [email, goTo, knownServerUrls, rememberEmail, t]);
const handleServerContinue = React.useCallback(() => {
const normalized = normalizeServerUrl(serverInput);
if (!normalized) {
setNotice({
- title: "That doesn't look like a server address",
- detail: 'Try the address you use for webmail, like mail.example.com.',
+ title: t('login.mobile.notice_bad_server', "That doesn't look like a server address"),
+ detail: t('login.mobile.notice_bad_server_detail', 'Try the address you use for webmail, like mail.example.com.'),
});
return;
}
setServerUrl(normalized);
setServerDiscovered(false);
goTo('confirm');
- }, [goTo, serverInput]);
+ }, [goTo, serverInput, t]);
const handlePasswordSubmit = React.useCallback(async () => {
const target = serverUrl || normalizeServerUrl(serverInput);
if (!target) {
- setNotice({ title: 'We need a server address first' });
+ setNotice({ title: t('login.mobile.notice_need_server', 'We need a server address first') });
return;
}
if (!email.trim() || !password) {
- setNotice({ title: 'Enter your email and password' });
+ setNotice({ title: t('login.mobile.notice_need_credentials', 'Enter your email and password') });
return;
}
if (totpRequired && !/^\d{6,8}$/.test(totp.trim())) {
- setNotice({ title: 'Enter the 6-digit code from your authenticator app' });
+ setNotice({ title: t('login.mobile.notice_need_code', 'Enter the 6-digit code from your authenticator app') });
return;
}
@@ -234,14 +266,14 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
} catch (err) {
if (err instanceof Error && err.name === 'TotpRequiredError') {
setTotpRequired(true);
- setNotice(describeLoginError(err, { serverUrl: target }));
+ setNotice(describeLoginError(err, { serverUrl: target, t }));
return;
}
- setNotice(describeLoginError(err, { serverUrl: target }));
+ setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
}
- }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired]);
+ }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]);
const handleScanned = React.useCallback(
async (data: string) => {
@@ -249,8 +281,8 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const payload = parseQrLoginPayload(data);
if (!payload) {
setNotice({
- title: "That code isn't a Bulwark sign-in code",
- detail: 'Open Bulwark on the web, then Settings → Security → Link device to show one.',
+ title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"),
+ detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link device to show one.'),
});
return;
}
@@ -272,12 +304,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
- setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl }));
+ setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t }));
} finally {
setBusy(null);
}
},
- [finishIfSignedIn, isAddMode, loginViaPairing, runHandoff],
+ [finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
);
// ── render ─────────────────────────────────────────────
@@ -352,6 +384,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}}
isSearching={searching}
notice={notice}
+ suggestions={emailSuggestions}
/>
) : null}
diff --git a/src/screens/login/ChooseStep.tsx b/src/screens/login/ChooseStep.tsx
index 66342bfb..fac602aa 100644
--- a/src/screens/login/ChooseStep.tsx
+++ b/src/screens/login/ChooseStep.tsx
@@ -6,6 +6,7 @@ import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store';
import OptionTile from './OptionTile';
import LoginNotice from './LoginNotice';
+import { useLocaleStore } from '../../stores/locale-store';
// The white mark disappears on the light palette, so pick per theme. `require`
// can't take an expression, hence the pair.
@@ -54,14 +55,15 @@ export default function ChooseStep({
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
return (
{isAddMode ? (
- Add another account
+ {t('login.mobile.add_title', 'Add another account')}
{accounts.length > 0 ? (
- You're signed in to these already.
+ {t('login.mobile.add_subtitle', "You're signed in to these already.")}
) : null}
) : (
@@ -72,7 +74,7 @@ export default function ChooseStep({
resizeMode="contain"
/>
Bulwark Mail
- Choose how you'd like to sign in.
+ {t('login.mobile.choose_subtitle', "Choose how you'd like to sign in.")}
)}
@@ -106,8 +108,8 @@ export default function ChooseStep({
{isAddMode && knownServerUrl ? (
}
onPress={onUseKnownServer}
disabled={disabled}
@@ -115,8 +117,8 @@ export default function ChooseStep({
) : (
}
onPress={onScan}
disabled={disabled}
@@ -124,8 +126,8 @@ export default function ChooseStep({
)}
}
onPress={onUseEmail}
disabled={disabled}
@@ -133,7 +135,7 @@ export default function ChooseStep({
{isAddMode && knownServerUrl ? (
}
onPress={onScan}
disabled={disabled}
@@ -143,7 +145,7 @@ export default function ChooseStep({
- Enter server details manually
+ {t('login.mobile.manual', 'Enter server details manually')}
);
diff --git a/src/screens/login/ConfirmStep.tsx b/src/screens/login/ConfirmStep.tsx
index f217b263..4cdb1936 100644
--- a/src/screens/login/ConfirmStep.tsx
+++ b/src/screens/login/ConfirmStep.tsx
@@ -5,6 +5,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { Button } from '../../components';
import LoginNotice from './LoginNotice';
+import { useLocaleStore } from '../../stores/locale-store';
interface ConfirmStepProps {
serverUrl: string;
@@ -35,14 +36,15 @@ export default function ConfirmStep({
}: ConfirmStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
return (
- {discovered ? 'Found your mail server' : 'Check this looks right'}
+ {discovered ? t('login.mobile.confirm_found', 'Found your mail server') : t('login.mobile.confirm_check', 'Check this looks right')}
- Next you'll sign in on your provider's own page.
+ {t('login.mobile.confirm_subtitle', "Next you'll sign in on your provider's own page.")}
@@ -56,7 +58,7 @@ export default function ConfirmStep({
{!serverUrl.startsWith('https://') ? (
- Unencrypted connection
+ {t('login.mobile.unencrypted', 'Unencrypted connection')}
) : null}
@@ -64,22 +66,22 @@ export default function ConfirmStep({
{notice ? : null}
- Sign in with a password instead
+ {t('login.mobile.use_password', 'Sign in with a password instead')}
- Use a different server
+ {t('login.mobile.different_server', 'Use a different server')}
diff --git a/src/screens/login/EmailStep.tsx b/src/screens/login/EmailStep.tsx
index 6e440b49..0581e9a8 100644
--- a/src/screens/login/EmailStep.tsx
+++ b/src/screens/login/EmailStep.tsx
@@ -5,6 +5,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
+import { useLocaleStore } from '../../stores/locale-store';
interface EmailStepProps {
isAddMode: boolean;
@@ -14,6 +15,8 @@ interface EmailStepProps {
onKnowServer: () => void;
isSearching: boolean;
notice?: { title: string; detail?: string } | null;
+ /** Addresses used before on this device, offered as one-tap fills. */
+ suggestions?: string[];
}
/**
@@ -28,18 +31,20 @@ export default function EmailStep({
onKnowServer,
isSearching,
notice,
+ suggestions = [],
}: EmailStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
return (
- {isAddMode ? "What's the other address?" : "What's your email address?"}
+ {isAddMode ? t('login.mobile.email_step_title_add', "What's the other address?") : t('login.mobile.email_step_title', "What's your email address?")}
- That's usually all we need to find your mail server.
+ {t('login.mobile.email_step_subtitle', "That's usually all we need to find your mail server.")}
@@ -59,16 +64,32 @@ export default function EmailStep({
leftIcon={}
/>
- Works with Stalwart and any other JMAP mail server.
+ {t('login.mobile.email_step_hint', 'Works with Stalwart and any other JMAP mail server.')}
+
+ {suggestions.length > 0 && !value ? (
+
+ {suggestions.slice(0, 5).map((address) => (
+ onChange(address)}
+ style={styles.suggestion}
+ accessibilityRole="button"
+ accessibilityLabel={address}
+ >
+ {address}
+
+ ))}
+
+ ) : null}
{notice ? : null}
- I know my server address
+ {t('login.mobile.know_server', 'I know my server address')}
);
@@ -81,6 +102,16 @@ function makeStyles(c: ThemePalette) {
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
+ suggestions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
+ suggestion: {
+ borderWidth: 1,
+ borderColor: c.border,
+ borderRadius: 999,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs,
+ maxWidth: '100%',
+ },
+ suggestionText: { ...typography.caption, color: c.textSecondary },
link: { alignItems: 'center', paddingVertical: spacing.sm },
linkText: { ...typography.body, color: c.textMuted },
});
diff --git a/src/screens/login/PasswordStep.tsx b/src/screens/login/PasswordStep.tsx
index f454b3f2..8fcc5ddb 100644
--- a/src/screens/login/PasswordStep.tsx
+++ b/src/screens/login/PasswordStep.tsx
@@ -5,6 +5,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
+import { useLocaleStore } from '../../stores/locale-store';
interface PasswordStepProps {
serverUrl: string;
@@ -40,19 +41,20 @@ export default function PasswordStep({
}: PasswordStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
const [showPassword, setShowPassword] = React.useState(false);
return (
- Sign in to {hostOf(serverUrl)}
+ {t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}
- Your password is sent to this server only, and stored in the device keychain.
+ {t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
setShowPassword((v) => !v)}
hitSlop={8}
accessibilityRole="button"
- accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
+ accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
>
{showPassword ? : }
@@ -88,7 +90,7 @@ export default function PasswordStep({
{totpRequired ? (
onChangeTotp?.(value)}
@@ -105,7 +107,7 @@ export default function PasswordStep({
{notice ? : null}
);
diff --git a/src/screens/login/ServerStep.tsx b/src/screens/login/ServerStep.tsx
index 3d8bc1c1..4fe74a3b 100644
--- a/src/screens/login/ServerStep.tsx
+++ b/src/screens/login/ServerStep.tsx
@@ -5,6 +5,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
+import { useLocaleStore } from '../../stores/locale-store';
interface ServerStepProps {
value: string;
@@ -31,20 +32,21 @@ export default function ServerStep({
}: ServerStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
return (
- What's your server address?
+ {t('login.mobile.server_title', "What's your server address?")}
{failedDomain
- ? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for webmail.`
- : 'Enter the address you use to reach your webmail.'}
+ ? t('login.mobile.server_not_found', "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.", { domain: failedDomain })
+ : t('login.mobile.server_subtitle', 'Enter the address you use to reach your webmail.')}
}
/>
- Same address you type in the browser. We'll add https:// for you.
+ {t('login.mobile.server_hint', "Same address you type in the browser. We'll add https:// for you.")}
{notice ? : null}
- If you're not sure, scan a sign-in code instead. It carries the server address.
+ {t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")}
diff --git a/src/screens/login/SigningInStep.tsx b/src/screens/login/SigningInStep.tsx
index 92a64bf3..4f87420d 100644
--- a/src/screens/login/SigningInStep.tsx
+++ b/src/screens/login/SigningInStep.tsx
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
+import { useLocaleStore } from '../../stores/locale-store';
// The white mark disappears on the light palette, so pick per theme.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
@@ -37,15 +38,16 @@ export default function SigningInStep({ phase, serverUrl, email }: SigningInStep
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
- const host = hostOf(serverUrl);
+ const t = useLocaleStore((s) => s.t);
+ const host = serverUrl ? hostOf(serverUrl) : t('login.mobile.your_server', 'your server');
const copy: Record = {
browser: {
- title: `Opening ${host}`,
- detail: 'Finish signing in on the page that just opened. We’ll take it from there.',
+ title: t('login.mobile.opening', 'Opening {host}', { host }),
+ detail: t('login.mobile.opening_detail', 'Finish signing in on the page that just opened. We’ll take it from there.'),
},
- connecting: { title: `Connecting to ${host}`, detail: 'Setting up your mailbox…' },
- pairing: { title: 'Redeeming your sign-in code', detail: `Pairing this device with ${host}…` },
+ connecting: { title: t('login.mobile.connecting', 'Connecting to {host}', { host }), detail: t('login.mobile.connecting_detail', 'Setting up your mailbox…') },
+ pairing: { title: t('login.mobile.pairing', 'Redeeming your sign-in code'), detail: t('login.mobile.pairing_detail', 'Pairing this device with {host}…', { host }) },
};
return (