feat: localize the sign-in flow and offer previously used addresses

This commit is contained in:
Linus Rath
2026-08-29 18:10:25 +02:00
parent 63bf3921b0
commit 240471764a
10 changed files with 327 additions and 104 deletions
+142 -2
View File
@@ -2,11 +2,30 @@
"calendar": { "calendar": {
"event_modal": { "event_modal": {
"participant_placeholder": "Add participant by email" "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": { "common": {
"add": "Add",
"back": "Back", "back": "Back",
"dismiss": "Dismiss" "create": "Create",
"dismiss": "Dismiss",
"done": "Done",
"remove": "Remove"
}, },
"context_menu": { "context_menu": {
"color_tag": "Tag", "color_tag": "Tag",
@@ -37,9 +56,14 @@
}, },
"email_list": { "email_list": {
"error": "Error", "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_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_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": { "email_viewer": {
"color_tag": { "color_tag": {
@@ -69,6 +93,92 @@
"share_search_placeholder": "Search by name or email", "share_search_placeholder": "Search by name or email",
"shared_with": "Shared with" "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": { "offline": {
"banner": "You are offline", "banner": "You are offline",
"queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect" "queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect"
@@ -80,6 +190,16 @@
"title": "Get notified about new mail" "title": "Get notified about new mail"
}, },
"settings": { "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": { "advanced": {
"about": { "about": {
"deprecated_update": "Update required: {version}", "deprecated_update": "Update required: {version}",
@@ -163,6 +283,22 @@
"mobile_description": "Plugins run inside the webmail and extend it in the browser.", "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." "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": { "smime": {
"mobile_description": "Sign and encrypt mail with S/MIME certificates.", "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." "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": { "updates": {
"advisory": "Security advisory", "advisory": "Security advisory",
"advisory_desc": "Details about the issue this release fixes.", "advisory_desc": "Details about the issue this release fixes.",
+8 -6
View File
@@ -6,6 +6,7 @@ import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors'; import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button'; import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
interface QrScanModalProps { interface QrScanModalProps {
visible: boolean; visible: boolean;
@@ -19,6 +20,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors(); const c = useColors();
const theme = useResolvedTheme(); const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]); const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]);
const t = useLocaleStore((st) => st.t);
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the // Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down. // same code before the modal tears down.
@@ -61,9 +63,9 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
</View> </View>
<View style={[styles.scrim, styles.scrimBottom]}> <View style={[styles.scrim, styles.scrimBottom]}>
<View style={styles.hintWrap}> <View style={styles.hintWrap}>
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text> <Text style={styles.hintTitle}>{t('login.mobile.qr_hint_title', 'Open Bulwark on your computer')}</Text>
<Text style={styles.hint}> <Text style={styles.hint}>
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.')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -75,7 +77,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}> <Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color={c.text} /> <X size={24} color={c.text} />
</Pressable> </Pressable>
<Text style={styles.title}>Sign-in code</Text> <Text style={styles.title}>{t('login.mobile.qr_title', 'Sign-in code')}</Text>
<View style={styles.closeButton} /> <View style={styles.closeButton} />
</View> </View>
@@ -83,11 +85,11 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
<View style={styles.permissionWrap}> <View style={styles.permissionWrap}>
<Text style={styles.permissionText}> <Text style={styles.permissionText}>
{permission && !permission.canAskAgain {permission && !permission.canAskAgain
? 'Camera access is disabled. Enable it in Settings 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.')
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'} : t('login.mobile.camera_needed', 'Bulwark Mail needs camera access to scan a sign-in QR code.')}
</Text> </Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}> <Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access {t('login.mobile.camera_allow', 'Allow camera access')}
</Button> </Button>
</View> </View>
) : null} ) : null}
+40 -33
View File
@@ -11,9 +11,13 @@ export interface LoginErrorCopy {
detail?: string; detail?: string;
} }
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
export interface LoginErrorContext { export interface LoginErrorContext {
/** Host shown in "can't reach X" copy. */ /** Host shown in "can't reach X" copy. */
serverUrl?: string | null; serverUrl?: string | null;
/** Translator; English copy is used when absent (tests, non-UI callers). */
t?: Translate;
} }
function messageOf(err: unknown): string { function messageOf(err: unknown): string {
@@ -24,29 +28,34 @@ function nameOf(err: unknown): string {
return err instanceof Error ? err.name : ''; return err instanceof Error ? err.name : '';
} }
function hostLabel(serverUrl: string | null | undefined): string { function hostLabel(serverUrl: string | null | undefined, fallback: string): string {
if (!serverUrl) return 'the server'; if (!serverUrl) return fallback;
const host = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0]; 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 { export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
const name = nameOf(err); const name = nameOf(err);
const message = messageOf(err); const message = messageOf(err);
const lower = message.toLowerCase(); 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')) { if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) {
return { return {
title: 'Enter your two-factor code', title: t('login.mobile.err_totp_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.', 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')) { if (name === 'TotpLoginError' && lower.includes('invalid')) {
return { return {
title: "That didn't work", title: t('login.mobile.err_bad_title', "That didn't work"),
detail: 'Check your password and the current code from your authenticator app, then try again.', 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 retryMs = (err as { retryAfterMs?: number } | null)?.retryAfterMs;
const seconds = typeof retryMs === 'number' && Number.isFinite(retryMs) ? Math.max(1, Math.round(retryMs / 1000)) : null; const seconds = typeof retryMs === 'number' && Number.isFinite(retryMs) ? Math.max(1, Math.round(retryMs / 1000)) : null;
return { return {
title: 'Too many attempts', title: t('login.mobile.err_rate_title', 'Too many attempts'),
detail: seconds detail: seconds
? `The server asked us to wait. Try again in about ${seconds} seconds.` ? t('login.mobile.err_rate_detail_seconds', 'The server asked us to wait. Try again in about {seconds} seconds.', { seconds })
: 'The server asked us to wait a moment. Try again shortly.', : 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)) { if (lower.includes('session discovery failed') && /\b402\b/.test(message)) {
return { return {
title: 'Enter your two-factor code', title: t('login.mobile.err_totp_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.', 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')) { if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
return { return {
title: "That didn't work", title: t('login.mobile.err_bad_title', "That didn't work"),
detail: 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.'),
'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')) { if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) {
return { return {
title: `Couldn't verify ${host}`, title: t('login.mobile.err_cert_title', "Couldn't verify {host}", { host }),
detail: 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."),
"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. // mistyped host or a webmail that lives on a subpath.
if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) { if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) {
return { return {
title: `No mail server at ${host}`, title: t('login.mobile.err_no_server_title', 'No mail server at {host}', { host }),
detail: 'Double-check the address, or scan a sign-in code from the webmail instead.', 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') lower.includes('timed out')
) { ) {
return { return {
title: `Can't reach ${host}`, title: t('login.mobile.err_unreachable_title', "Can't reach {host}", { host }),
detail: 'Check your connection and the server address, then try again.', 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)) { if (lower.includes('session discovery failed') && /\b5\d\d\b/.test(message)) {
return { return {
title: `${host} is having trouble`, title: t('login.mobile.err_server_title', '{host} is having trouble', { host }),
detail: 'The server answered with an error. Try again in a few minutes.', 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')) { if (lower.includes('no bulwark webmail or sign-in service')) {
return { return {
title: `No sign-in page at ${host}`, title: t('login.mobile.err_no_webmail_title', 'No sign-in page at {host}', { host }),
detail: 'This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.', 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')) { if (lower.includes('pairing code')) {
return { return {
title: 'That code has expired', title: t('login.mobile.err_code_expired_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.', 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')) { if (lower.includes('state mismatch')) {
return { return {
title: 'Sign-in was interrupted', title: t('login.mobile.err_interrupted_title', 'Sign-in was interrupted'),
detail: "The response didn't match the request we started. Try signing in again.", 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. // Unrecognised: show what we were told rather than inventing a cause.
return { return {
title: 'Sign-in failed', title: t('login.mobile.err_generic_title', 'Sign-in failed'),
detail: message || 'Something went wrong. Try again.', detail: message || t('login.mobile.err_generic_detail', 'Something went wrong. Try again.'),
}; };
} }
+52 -19
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { BackHandler } from 'react-native'; import { BackHandler } from 'react-native';
import { useAuthStore } from '../stores/auth-store'; import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-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 { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth'; import { parseQrLoginPayload } from '../lib/oauth';
import { import {
@@ -27,6 +29,8 @@ interface LoginScreenProps {
type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password'; type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
/** /**
* Sign-in, one question per screen, cheapest question first. * 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 clearError = useAuthStore((state) => state.clearError);
const storeError = useAuthStore((state) => state.error); const storeError = useAuthStore((state) => state.error);
const t = useLocaleStore((state) => state.t);
const accounts = useAccountStore((state) => state.accounts); const accounts = useAccountStore((state) => state.accounts);
const activeAccountId = useAccountStore((state) => state.activeAccountId); const activeAccountId = useAccountStore((state) => state.activeAccountId);
@@ -65,6 +70,32 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const [busy, setBusy] = React.useState<SigningInPhase | null>(null); const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
const [scannerVisible, setScannerVisible] = React.useState(false); 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<string[]>([]);
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 // Servers we already hold credentials for. Discovery trusts these without a
// probe, which makes "second account on the same host" instant. // probe, which makes "second account on the same host" instant.
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]); 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); setServerUrl(pending.serverUrl);
setTotpRequired(true); setTotpRequired(true);
setBusy(null); setBusy(null);
setNotice(describeLoginError(err, { serverUrl: target })); setNotice(describeLoginError(err, { serverUrl: target, t }));
setHistory((prev) => [...prev, step]); setHistory((prev) => [...prev, step]);
setStep('password'); setStep('password');
return; return;
} }
setNotice(describeLoginError(err, { serverUrl: target })); setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
}, },
[finishIfSignedIn, isAddMode, loginViaWebmail, step], [finishIfSignedIn, isAddMode, loginViaWebmail, step, t],
); );
const handleEmailContinue = React.useCallback(async () => { const handleEmailContinue = React.useCallback(async () => {
const value = email.trim(); const value = email.trim();
if (!isEmailAddress(value)) { if (!isEmailAddress(value)) {
setNotice({ setNotice({
title: 'Enter a full email address', title: t('login.mobile.notice_full_email', 'Enter a full email address'),
detail: 'Like ada@example.com. If you sign in with a username instead, use “I know my server 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; return;
} }
setNotice(null); setNotice(null);
setSearching(true); setSearching(true);
rememberEmail(value);
try { try {
const found = await discoverServerForEmail(value, { knownServerUrls }); const found = await discoverServerForEmail(value, { knownServerUrls });
if (found) { if (found) {
@@ -190,35 +222,35 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
} finally { } finally {
setSearching(false); setSearching(false);
} }
}, [email, goTo, knownServerUrls]); }, [email, goTo, knownServerUrls, rememberEmail, t]);
const handleServerContinue = React.useCallback(() => { const handleServerContinue = React.useCallback(() => {
const normalized = normalizeServerUrl(serverInput); const normalized = normalizeServerUrl(serverInput);
if (!normalized) { if (!normalized) {
setNotice({ setNotice({
title: "That doesn't look like a server address", title: t('login.mobile.notice_bad_server', "That doesn't look like a server address"),
detail: 'Try the address you use for webmail, like mail.example.com.', detail: t('login.mobile.notice_bad_server_detail', 'Try the address you use for webmail, like mail.example.com.'),
}); });
return; return;
} }
setServerUrl(normalized); setServerUrl(normalized);
setServerDiscovered(false); setServerDiscovered(false);
goTo('confirm'); goTo('confirm');
}, [goTo, serverInput]); }, [goTo, serverInput, t]);
const handlePasswordSubmit = React.useCallback(async () => { const handlePasswordSubmit = React.useCallback(async () => {
const target = serverUrl || normalizeServerUrl(serverInput); const target = serverUrl || normalizeServerUrl(serverInput);
if (!target) { 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; return;
} }
if (!email.trim() || !password) { 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; return;
} }
if (totpRequired && !/^\d{6,8}$/.test(totp.trim())) { 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; return;
} }
@@ -234,14 +266,14 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === 'TotpRequiredError') { if (err instanceof Error && err.name === 'TotpRequiredError') {
setTotpRequired(true); setTotpRequired(true);
setNotice(describeLoginError(err, { serverUrl: target })); setNotice(describeLoginError(err, { serverUrl: target, t }));
return; return;
} }
setNotice(describeLoginError(err, { serverUrl: target })); setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally { } finally {
setBusy(null); 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( const handleScanned = React.useCallback(
async (data: string) => { async (data: string) => {
@@ -249,8 +281,8 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const payload = parseQrLoginPayload(data); const payload = parseQrLoginPayload(data);
if (!payload) { if (!payload) {
setNotice({ setNotice({
title: "That code isn't a Bulwark sign-in code", title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"),
detail: 'Open Bulwark on the web, then Settings → Security → Link device to show one.', detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link device to show one.'),
}); });
return; return;
} }
@@ -272,12 +304,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated); finishIfSignedIn(wasAuthenticated);
} catch (err) { } catch (err) {
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl })); setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
}, },
[finishIfSignedIn, isAddMode, loginViaPairing, runHandoff], [finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
); );
// ── render ───────────────────────────────────────────── // ── render ─────────────────────────────────────────────
@@ -352,6 +384,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}} }}
isSearching={searching} isSearching={searching}
notice={notice} notice={notice}
suggestions={emailSuggestions}
/> />
) : null} ) : null}
+13 -11
View File
@@ -6,6 +6,7 @@ import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store'; import type { AccountEntry } from '../../stores/account-store';
import OptionTile from './OptionTile'; import OptionTile from './OptionTile';
import LoginNotice from './LoginNotice'; import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
// The white mark disappears on the light palette, so pick per theme. `require` // The white mark disappears on the light palette, so pick per theme. `require`
// can't take an expression, hence the pair. // can't take an expression, hence the pair.
@@ -54,14 +55,15 @@ export default function ChooseStep({
const c = useColors(); const c = useColors();
const theme = useResolvedTheme(); const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.root}> <View style={styles.root}>
{isAddMode ? ( {isAddMode ? (
<View style={styles.addHeading}> <View style={styles.addHeading}>
<Text style={styles.title}>Add another account</Text> <Text style={styles.title}>{t('login.mobile.add_title', 'Add another account')}</Text>
{accounts.length > 0 ? ( {accounts.length > 0 ? (
<Text style={styles.subtitle}>You&apos;re signed in to these already.</Text> <Text style={styles.subtitle}>{t('login.mobile.add_subtitle', "You're signed in to these already.")}</Text>
) : null} ) : null}
</View> </View>
) : ( ) : (
@@ -72,7 +74,7 @@ export default function ChooseStep({
resizeMode="contain" resizeMode="contain"
/> />
<Text style={styles.title}>Bulwark Mail</Text> <Text style={styles.title}>Bulwark Mail</Text>
<Text style={styles.subtitle}>Choose how you&apos;d like to sign in.</Text> <Text style={styles.subtitle}>{t('login.mobile.choose_subtitle', "Choose how you'd like to sign in.")}</Text>
</View> </View>
)} )}
@@ -106,8 +108,8 @@ export default function ChooseStep({
{isAddMode && knownServerUrl ? ( {isAddMode && knownServerUrl ? (
<OptionTile <OptionTile
emphasis="primary" emphasis="primary"
title={`Another account on ${hostOf(knownServerUrl)}`} title={t('login.mobile.known_server_title', 'Another account on {host}', { host: hostOf(knownServerUrl) })}
description="We already know this server, so there's no setup" description={t('login.mobile.known_server_desc', "We already know this server, so there's no setup")}
renderIcon={(color, size) => <Plus size={size} color={color} />} renderIcon={(color, size) => <Plus size={size} color={color} />}
onPress={onUseKnownServer} onPress={onUseKnownServer}
disabled={disabled} disabled={disabled}
@@ -115,8 +117,8 @@ export default function ChooseStep({
) : ( ) : (
<OptionTile <OptionTile
emphasis="primary" emphasis="primary"
title="Scan a sign-in code" title={t('login.mobile.scan_title', 'Scan a sign-in code')}
description="The fastest way in if you're signed in on the web" description={t('login.mobile.scan_desc', "The fastest way in if you're signed in on the web")}
renderIcon={(color, size) => <QrCode size={size} color={color} />} renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan} onPress={onScan}
disabled={disabled} disabled={disabled}
@@ -124,8 +126,8 @@ export default function ChooseStep({
)} )}
<OptionTile <OptionTile
title={isAddMode ? 'An account somewhere else' : 'Use my email address'} title={isAddMode ? t('login.mobile.elsewhere_title', 'An account somewhere else') : t('login.mobile.email_title', 'Use my email address')}
description="We'll find your mail server" description={t('login.mobile.email_desc', "We'll find your mail server")}
renderIcon={(color, size) => <Mail size={size} color={color} />} renderIcon={(color, size) => <Mail size={size} color={color} />}
onPress={onUseEmail} onPress={onUseEmail}
disabled={disabled} disabled={disabled}
@@ -133,7 +135,7 @@ export default function ChooseStep({
{isAddMode && knownServerUrl ? ( {isAddMode && knownServerUrl ? (
<OptionTile <OptionTile
title="Scan a sign-in code" title={t('login.mobile.scan_title', 'Scan a sign-in code')}
renderIcon={(color, size) => <QrCode size={size} color={color} />} renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan} onPress={onScan}
disabled={disabled} disabled={disabled}
@@ -143,7 +145,7 @@ export default function ChooseStep({
<Pressable onPress={onManualSetup} disabled={disabled} hitSlop={8} style={styles.manual}> <Pressable onPress={onManualSetup} disabled={disabled} hitSlop={8} style={styles.manual}>
<Server size={14} color={c.textMuted} /> <Server size={14} color={c.textMuted} />
<Text style={styles.manualText}>Enter server details manually</Text> <Text style={styles.manualText}>{t('login.mobile.manual', 'Enter server details manually')}</Text>
</Pressable> </Pressable>
</View> </View>
); );
+10 -8
View File
@@ -5,6 +5,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { Button } from '../../components'; import { Button } from '../../components';
import LoginNotice from './LoginNotice'; import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
interface ConfirmStepProps { interface ConfirmStepProps {
serverUrl: string; serverUrl: string;
@@ -35,14 +36,15 @@ export default function ConfirmStep({
}: ConfirmStepProps) { }: ConfirmStepProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={styles.heading}> <View style={styles.heading}>
<Text style={styles.title}> <Text style={styles.title}>
{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')}
</Text> </Text>
<Text style={styles.subtitle}>Next you&apos;ll sign in on your provider&apos;s own page.</Text> <Text style={styles.subtitle}>{t('login.mobile.confirm_subtitle', "Next you'll sign in on your provider's own page.")}</Text>
</View> </View>
<View style={styles.card}> <View style={styles.card}>
@@ -56,7 +58,7 @@ export default function ConfirmStep({
{!serverUrl.startsWith('https://') ? ( {!serverUrl.startsWith('https://') ? (
<View style={styles.cardMetaRow}> <View style={styles.cardMetaRow}>
<View style={styles.dot} /> <View style={styles.dot} />
<Text style={styles.cardMeta}>Unencrypted connection</Text> <Text style={styles.cardMeta}>{t('login.mobile.unencrypted', 'Unencrypted connection')}</Text>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -64,22 +66,22 @@ export default function ConfirmStep({
<LoginNotice <LoginNotice
tone="info" tone="info"
title="Your password stays with your provider" title={t('login.mobile.provider_title', 'Your password stays with your provider')}
detail="You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time." detail={t('login.mobile.provider_detail', "You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time.")}
/> />
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null} {notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onContinue}> <Button variant="default" size="md" onPress={onContinue}>
Continue {t('login.mobile.continue', 'Continue')}
</Button> </Button>
<View style={styles.links}> <View style={styles.links}>
<Pressable onPress={onUsePassword} hitSlop={8}> <Pressable onPress={onUsePassword} hitSlop={8}>
<Text style={styles.link}>Sign in with a password instead</Text> <Text style={styles.link}>{t('login.mobile.use_password', 'Sign in with a password instead')}</Text>
</Pressable> </Pressable>
<Pressable onPress={onChangeServer} hitSlop={8}> <Pressable onPress={onChangeServer} hitSlop={8}>
<Text style={styles.linkMuted}>Use a different server</Text> <Text style={styles.linkMuted}>{t('login.mobile.different_server', 'Use a different server')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
+36 -5
View File
@@ -5,6 +5,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components'; import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice'; import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
interface EmailStepProps { interface EmailStepProps {
isAddMode: boolean; isAddMode: boolean;
@@ -14,6 +15,8 @@ interface EmailStepProps {
onKnowServer: () => void; onKnowServer: () => void;
isSearching: boolean; isSearching: boolean;
notice?: { title: string; detail?: string } | null; 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, onKnowServer,
isSearching, isSearching,
notice, notice,
suggestions = [],
}: EmailStepProps) { }: EmailStepProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={styles.heading}> <View style={styles.heading}>
<Text style={styles.title}> <Text style={styles.title}>
{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?")}
</Text> </Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
That&apos;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.")}
</Text> </Text>
</View> </View>
@@ -59,16 +64,32 @@ export default function EmailStep({
leftIcon={<Mail size={17} color={c.textMuted} />} leftIcon={<Mail size={17} color={c.textMuted} />}
/> />
<Text style={styles.hint}>Works with Stalwart and any other JMAP mail server.</Text> <Text style={styles.hint}>{t('login.mobile.email_step_hint', 'Works with Stalwart and any other JMAP mail server.')}</Text>
{suggestions.length > 0 && !value ? (
<View style={styles.suggestions}>
{suggestions.slice(0, 5).map((address) => (
<Pressable
key={address}
onPress={() => onChange(address)}
style={styles.suggestion}
accessibilityRole="button"
accessibilityLabel={address}
>
<Text style={styles.suggestionText} numberOfLines={1}>{address}</Text>
</Pressable>
))}
</View>
) : null}
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null} {notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit} loading={isSearching}> <Button variant="default" size="md" onPress={onSubmit} loading={isSearching}>
{isSearching ? 'Looking up your server…' : 'Continue'} {isSearching ? t('login.mobile.looking_up', 'Looking up your server…') : t('login.mobile.continue', 'Continue')}
</Button> </Button>
<Pressable onPress={onKnowServer} disabled={isSearching} hitSlop={8} style={styles.link}> <Pressable onPress={onKnowServer} disabled={isSearching} hitSlop={8} style={styles.link}>
<Text style={styles.linkText}>I know my server address</Text> <Text style={styles.linkText}>{t('login.mobile.know_server', 'I know my server address')}</Text>
</Pressable> </Pressable>
</View> </View>
); );
@@ -81,6 +102,16 @@ function makeStyles(c: ThemePalette) {
title: { ...typography.h1, color: c.text }, title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary }, subtitle: { ...typography.body, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm }, 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 }, link: { alignItems: 'center', paddingVertical: spacing.sm },
linkText: { ...typography.body, color: c.textMuted }, linkText: { ...typography.body, color: c.textMuted },
}); });
+10 -8
View File
@@ -5,6 +5,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components'; import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice'; import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
interface PasswordStepProps { interface PasswordStepProps {
serverUrl: string; serverUrl: string;
@@ -40,19 +41,20 @@ export default function PasswordStep({
}: PasswordStepProps) { }: PasswordStepProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = React.useState(false);
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={styles.heading}> <View style={styles.heading}>
<Text style={styles.title}>Sign in to {hostOf(serverUrl)}</Text> <Text style={styles.title}>{t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
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.')}
</Text> </Text>
</View> </View>
<Input <Input
label="Email or username" label={t('login.mobile.username_label', 'Email or username')}
placeholder="you@example.com" placeholder="you@example.com"
value={email} value={email}
onChangeText={onChangeEmail} onChangeText={onChangeEmail}
@@ -64,8 +66,8 @@ export default function PasswordStep({
/> />
<Input <Input
label="Password" label={t('login.password_label', 'Password')}
placeholder="Enter your password" placeholder={t('login.password_placeholder', 'Enter your password')}
value={password} value={password}
onChangeText={onChangePassword} onChangeText={onChangePassword}
autoFocus={Boolean(email)} autoFocus={Boolean(email)}
@@ -79,7 +81,7 @@ export default function PasswordStep({
onPress={() => setShowPassword((v) => !v)} onPress={() => setShowPassword((v) => !v)}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'} accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
> >
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />} {showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
</Pressable> </Pressable>
@@ -88,7 +90,7 @@ export default function PasswordStep({
{totpRequired ? ( {totpRequired ? (
<Input <Input
label="Two-factor code" label={t('login.totp_label', 'Two-factor code')}
placeholder="123456" placeholder="123456"
value={totp} value={totp}
onChangeText={(value) => onChangeTotp?.(value)} onChangeText={(value) => onChangeTotp?.(value)}
@@ -105,7 +107,7 @@ export default function PasswordStep({
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null} {notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit}> <Button variant="default" size="md" onPress={onSubmit}>
Sign in {t('login.sign_in', 'Sign in')}
</Button> </Button>
</View> </View>
); );
+9 -7
View File
@@ -5,6 +5,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components'; import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice'; import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
interface ServerStepProps { interface ServerStepProps {
value: string; value: string;
@@ -31,20 +32,21 @@ export default function ServerStep({
}: ServerStepProps) { }: ServerStepProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.root}> <View style={styles.root}>
<View style={styles.heading}> <View style={styles.heading}>
<Text style={styles.title}>What&apos;s your server address?</Text> <Text style={styles.title}>{t('login.mobile.server_title', "What's your server address?")}</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
{failedDomain {failedDomain
? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for 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 })
: 'Enter the address you use to reach your webmail.'} : t('login.mobile.server_subtitle', 'Enter the address you use to reach your webmail.')}
</Text> </Text>
</View> </View>
<Input <Input
label="Server address" label={t('login.mobile.server_label', 'Server address')}
placeholder="mail.example.com" placeholder="mail.example.com"
value={value} value={value}
onChangeText={onChange} onChangeText={onChange}
@@ -57,18 +59,18 @@ export default function ServerStep({
leftIcon={<Globe size={17} color={c.textMuted} />} leftIcon={<Globe size={17} color={c.textMuted} />}
/> />
<Text style={styles.hint}>Same address you type in the browser. We&apos;ll add https:// for you.</Text> <Text style={styles.hint}>{t('login.mobile.server_hint', "Same address you type in the browser. We'll add https:// for you.")}</Text>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null} {notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit}> <Button variant="default" size="md" onPress={onSubmit}>
Continue {t('login.mobile.continue', 'Continue')}
</Button> </Button>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}> <Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
<QrCode size={15} color={c.textMuted} /> <QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}> <Text style={styles.scanText}>
If you&apos;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.")}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
+7 -5
View File
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors'; import { useColors, useResolvedTheme } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
// The white mark disappears on the light palette, so pick per theme. // The white mark disappears on the light palette, so pick per theme.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png'); 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 c = useColors();
const theme = useResolvedTheme(); const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]); 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<SigningInPhase, { title: string; detail: string }> = { const copy: Record<SigningInPhase, { title: string; detail: string }> = {
browser: { browser: {
title: `Opening ${host}`, title: t('login.mobile.opening', 'Opening {host}', { host }),
detail: 'Finish signing in on the page that just opened. We’ll take it from there.', 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…' }, connecting: { title: t('login.mobile.connecting', 'Connecting to {host}', { host }), detail: t('login.mobile.connecting_detail', 'Setting up your mailbox…') },
pairing: { title: 'Redeeming your sign-in code', detail: `Pairing this device with ${host}…` }, pairing: { title: t('login.mobile.pairing', 'Redeeming your sign-in code'), detail: t('login.mobile.pairing_detail', 'Pairing this device with {host}…', { host }) },
}; };
return ( return (