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 (