mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: localize the sign-in flow and offer previously used addresses
This commit is contained in:
+142
-2
@@ -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.",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'll sign in on your provider'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>
|
||||||
|
|||||||
@@ -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'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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'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'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'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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user