Compare commits

...
7 Commits
Author SHA1 Message Date
Linus Rath 724c3b80ee chore: update version number to 0.1.67 2026-09-27 10:15:08 +02:00
Linus Rath a27d014c59 fix: ask before a sign-in link from another app signs in
Any web page (one tap) or installed app (no tap) could fire a
`bulwarkmail://pair` link at the app, which then silently added the
sender's mailbox and switched to it, so the user might send or save mail
into an attacker's account. A `connect` link likewise opened any
server's sign-in page.

- Links that arrive from outside the app now ask first, naming the
  webmail's host and whether the link adds an account. Cancel discards
  the link for good; a link delivered again while the question is open
  is dropped. Codes scanned or pasted inside the app still go straight
  through.
- With the account registry full, a link shows the limit instead of
  opening "Add account".
- A pairing link for a plain-http webmail says the connection would be
  unencrypted instead of "That code isn't a Bulwark sign-in code", or
  nothing at all for a tapped link.
2026-09-27 05:11:07 +02:00
Linus Rath 26bb947940 fix: keep the account limit from leaving the app signed in as the wrong account
Signing in connects the shared client to the new account and stores its
credentials before the account registry takes it. With ten accounts the
registry refused, but nothing undid the connect: every request then went
out as the new account while the app still showed the old one. A
sign-in link made this easy to reach, since it opened "Add account"
without the limit check the menus have.

- A failed registration now restores the previous connection and drops
  credentials the registry never took.
- Adding an account to a full registry is refused before a sign-in code
  is spent or a browser sign-in starts, with "Remove an account in
  Settings to add another one."
2026-09-27 05:10:50 +02:00
Linus Rath 1131073323 feat: open sign-in links from the camera app and paste them into the app
The webmail's QR encodes a `bulwarkmail://pair?...` link, but nothing
registered that scheme: scanning it with the system camera or Google
Lens did nothing, and without a camera there was no way in at all.

- `bulwarkmail` is registered in `app.config.js` (iOS) and the committed
  AndroidManifest. Links are parked in a small store and picked up by
  the sign-in screen, or by "Add account" when already signed in, once
  per launch.
- "Paste sign-in link" on the sign-in screen (and next to the server
  step's scan hint) takes a link copied from the webmail.
- While pairing, the waiting screen names the webmail being paired with.
2026-09-27 04:23:01 +02:00
Linus Rath 59cfe50793 fix: say why a sign-in code failed and accept password pairing from the webmail
Every failed redeem read "That code has expired": a code already used,
a webmail without pairing (404), a rate limit, a server error and a
network failure all looked the same, and a non-JSON answer surfaced as a
raw parse error. The app now reads the webmail's `invalid_code`,
`expired_code` and `used_code` answers (webmail 1.11's catch-all 400
reads as "expired or already used"), gives up after 15 s, and names the
host involved; a failure after the code was redeemed says the phone was
signed in but could not reach the mail server.

- `loginViaPairing` accepts `flow: 'password'` bundles (an app password,
  or the account password from servers without OAuth) in normal and
  add-account mode, and never redeems the same code twice.
- A `pair` link must point at an https webmail (loopback http for
  development); `pair/?` is accepted too.
- A token refresh that answers 200 with a non-JSON body keeps the
  account instead of signing it out.
2026-09-27 04:22:33 +02:00
Linus Rath db07acc37c chore: update version number to 0.1.66 2026-09-27 00:34:53 +02:00
Linus Rath 39628d84dc fix: keep widget corners and border even when the launcher crops the widget 2026-09-27 00:34:29 +02:00
62 changed files with 3341 additions and 227 deletions
+60 -1
View File
@@ -73,7 +73,16 @@ import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
import {
acceptSignInLink,
handleDeepLink,
parseDeepLink,
routeParkedSignInLink,
shareToDeepLink,
type DeepLink,
} from './src/navigation/linking';
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
import { MAX_ACCOUNTS } from './src/lib/account-utils';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
@@ -86,6 +95,10 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
// The launch URL is read once per process: Android hands the same intent back
// on every getInitialURL(), and a sign-in link must not run twice.
let initialSignInLinkRead = false;
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
@@ -566,6 +579,52 @@ export default function App() {
};
}, [isAuthenticated]);
// Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App,
// `bulwarkmail://connect?…`) work signed out too, so unlike the links above
// they are listened for from launch. They are parked for the login screen
// (the signed-out one takes them as it mounts), marked as coming from
// outside the app: any page or app can fire one, so the login screen asks
// before it redeems or opens anything.
React.useEffect(() => {
if (!initialSignInLinkRead) {
initialSignInLinkRead = true;
void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined);
}
const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); });
return () => subscription.remove();
}, []);
// Signed in, a sign-in link adds an account: open Add account, whose login
// screen takes the parked link (or says why a refused one won't be used).
// On a cold start the navigator mounts right after the auth gate flips, so
// wait for it like a reminder tap does.
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
const addAccountForLink = isAuthenticated && signInLinkPending;
React.useEffect(() => {
if (!addAccountForLink) return;
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (cancelled) return;
}
if (cancelled || !navigationRef.isReady()) return;
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
if (route === 'account-limit') {
// Said like the Add account buttons say it, instead of opening a
// screen whose sign-in can only fail.
const t = useLocaleStore.getState().t;
toast.error(
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
);
return;
}
if (route === 'add-account') navigationRef.navigate('AddAccount');
})();
return () => { cancelled = true; };
}, [addAccountForLink]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down THIS account's subscription so
+1 -1
View File
@@ -1 +1 @@
0.1.65
0.1.67
+7
View File
@@ -28,6 +28,13 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
<!-- Sign-in links from the webmail (bulwarkmail://pair?..., bulwarkmail://connect?...). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmail"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/widget_frame" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
layout-night/rn_widget.xml is its night twin).
The library draws each widget into one bitmap sized from the size the
launcher reports, and pins it to the top left without scaling. When the real
frame is smaller (after a rotation, or on a launcher that over-reports), the
bitmap's bottom edge is cut off and takes the rounded corners and the border
with it, so the bottom corners fall back to whatever the launcher clips to.
This root always has the real size, so it owns the shape instead: it clips to
a rounded outline (Android 12+; the launcher then leaves the corners to us),
fills in where the bitmap falls short, and draws the frame on top.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -3,4 +3,6 @@
<color name="splashscreen_background">#09090b</color>
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
<color name="window_background">#09090b</color>
<!-- DARK.bg in src/widgets/theme.ts. -->
<color name="widget_surface">#0a0a0a</color>
</resources>
@@ -6,4 +6,8 @@
<color name="colorPrimaryDark">#09090b</color>
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
<color name="window_background">#ffffff</color>
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
<color name="widget_surface">#ffffff</color>
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
<color name="widget_frame">#4d808080</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
<dimen name="widget_radius">22dp</dimen>
</resources>
+4 -2
View File
@@ -26,8 +26,10 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
// mailto: lets Android/iOS offer the app for mail links in other apps.
scheme: ['bulwarkmobile', 'mailto'],
// mailto: lets Android/iOS offer the app for mail links in other apps;
// bulwarkmail: is the webmail's sign-in link (bulwarkmail://pair?...).
// Android's manifest is maintained by hand and lists the same schemes.
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "ما عنوان بريدك الإلكتروني؟",
"email_step_title_add": "ما العنوان الآخر؟",
"email_title": "استخدام عنوان بريدي الإلكتروني",
"err_account_limit_detail": "أزِل حسابًا من الإعدادات لإضافة حساب آخر.",
"err_bad_detail": "تحقق من بريدك الإلكتروني وكلمة المرور. إذا كان حسابك يستخدم تسجيل الدخول بخطوتين، فأنشئ كلمة مرور تطبيق في بريد الويب واستخدمها هنا.",
"err_bad_title": "لم ينجح ذلك",
"err_cert_detail": "تم رفض شهادة الأمان الخاصة بالخادم. إذا كان هذا خادمك الخاص، فتحقق من أن الشهادة صالحة وغير منتهية الصلاحية.",
"err_cert_title": "تعذّر التحقق من {host}",
"err_code_expired_detail": "رموز تسجيل الدخول صالحة لبضع دقائق فقط. أنشئ رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
"err_code_expired_detail": "رموز تسجيل الدخول صالحة لمدة دقيقتين. اعرض رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
"err_code_expired_or_used_detail": "اعرض رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
"err_code_expired_or_used_title": "انتهت صلاحية هذا الرمز أو تم استخدامه من قبل",
"err_code_expired_title": "انتهت صلاحية هذا الرمز",
"err_code_invalid_detail": "اعرض رمزًا جديدًا في بريد الويب (الإعدادات ← الأمان ← ربط تطبيق الجوال) وامسحه.",
"err_code_invalid_title": "هذا الرمز غير صالح",
"err_code_used_detail": "كل رمز يعمل مرة واحدة فقط. اعرض رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
"err_code_used_title": "تم استخدام هذا الرمز من قبل",
"err_generic_detail": "حدث خطأ ما. حاول مرة أخرى.",
"err_generic_title": "فشل تسجيل الدخول",
"err_interrupted_detail": "لم تتطابق الاستجابة مع الطلب الذي بدأناه. حاول تسجيل الدخول مرة أخرى.",
@@ -474,6 +481,17 @@
"err_no_server_title": "لا يوجد خادم بريد على {host}",
"err_no_webmail_detail": "لا يحتوي هذا الخادم على بريد ويب Bulwark ولا على تسجيل دخول عبر OAuth. استخدم كلمة مرور (أو كلمة مرور تطبيق) بدلًا من ذلك.",
"err_no_webmail_title": "لا توجد صفحة تسجيل دخول على {host}",
"err_pair_connect_detail": "اعرض رمزًا جديدًا على حاسوبك وامسحه مجددًا.",
"err_pair_connect_title": "تم تسجيل الدخول، لكن تعذّر الاتصال بـ {host}",
"err_pair_insecure_detail": "يريد {host} استخدام اتصال غير مشفّر.",
"err_pair_network_detail": "تحقق من اتصالك ومن أن هاتفك يستطيع الوصول إلى بريد الويب، ثم حاول مرة أخرى.",
"err_pair_rate_detail": "انتظر دقيقة، ثم حاول مرة أخرى.",
"err_pair_server_detail": "حاول مرة أخرى باستخدام رمز جديد. إذا استمر الفشل، فاتصل بالمسؤول.",
"err_pair_server_title": "تعذّر على بريد الويب إكمال تسجيل الدخول",
"err_pair_unsupported_detail": "لا يدعم {host} رموز تسجيل الدخول. قد يحتاج إلى تحديث، أو أن الرابط يشير إلى عنوان خاطئ.",
"err_pair_unsupported_title": "لا يمكن لبريد الويب هذا ربط الأجهزة",
"err_pair_untrusted_detail": "أرسل {host} بيانات تسجيل دخول لخادم لا يستطيع التطبيق التحقق منه.",
"err_pair_untrusted_title": "لا يمكن الوثوق بعملية تسجيل الدخول هذه",
"err_rate_detail": "طلب منا الخادم الانتظار قليلًا. حاول مجددًا بعد قليل.",
"err_rate_detail_seconds": "طلب منا الخادم الانتظار. حاول مجددًا بعد نحو {seconds} ث.",
"err_rate_title": "محاولات كثيرة جدًا",
@@ -487,6 +505,12 @@
"know_server": "أعرف عنوان خادمي",
"known_server_desc": "نعرف هذا الخادم بالفعل، لذا لا حاجة إلى إعداد",
"known_server_title": "حساب آخر على {host}",
"link_confirm_connect": "يطلب رابط تسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.",
"link_confirm_connect_add": "يطلب رابط إضافة حساب بتسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.",
"link_confirm_connect_title": "فتح صفحة تسجيل الدخول في {host}؟",
"link_confirm_pair": "يسجّل هذا الرابط الدخول إلى حساب من {host}. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.",
"link_confirm_pair_add": "يضيف هذا الرابط حسابًا من {host} وينقل التطبيق إليه. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.",
"link_confirm_title": "تسجيل الدخول برابط؟",
"looking_up": "جارٍ البحث عن خادمك…",
"manual": "إدخال تفاصيل الخادم يدويًا",
"notice_bad_qr": "هذا الرمز ليس رمز تسجيل دخول Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "جارٍ إقران هذا الجهاز مع {host}…",
"password_subtitle": "تُرسَل كلمة مرورك إلى هذا الخادم فقط، وتُخزَّن في سلسلة مفاتيح الجهاز.",
"password_title": "تسجيل الدخول إلى {host}",
"paste_button": "لصق",
"paste_clipboard_empty": "لا يوجد شيء للصق. انسخ الرابط في بريد الويب أولًا.",
"paste_hint": "في بريد الويب، افتح الإعدادات ← الأمان ← ربط تطبيق الجوال، وانسخ رابط تسجيل الدخول والصقه هنا.",
"paste_invalid": "هذا ليس رابط تسجيل دخول Bulwark",
"paste_label": "رابط تسجيل الدخول",
"paste_title": "لصق رابط تسجيل الدخول",
"provider_detail": "ستكتبها في صفحتهم. لا يخزّن Bulwark سوى رمز تسجيل دخول يمكنك إبطاله في أي وقت.",
"provider_title": "تبقى كلمة مرورك لدى مزوّد خدمتك",
"qr_hint": "يعرض قسم الإعدادات ← الأمان ← ربط تطبيق الجوال رمزًا. وجّه الكاميرا نحوه.",
@@ -515,6 +545,7 @@
"server_hint": "العنوان نفسه الذي تكتبه في المتصفح. سنضيف https:// نيابةً عنك.",
"server_label": "عنوان الخادم",
"server_not_found": "لم نتمكن من العثور على خادم للنطاق {domain}. إذا كنت تستضيف خادمك بنفسك، فالعنوان هو الذي تستخدمه لبريد الويب.",
"server_paste_hint": "هل لديك رابط تسجيل دخول بدلًا من ذلك؟ الصقه هنا.",
"server_scan_hint": "إذا لم تكن متأكدًا، فامسح رمز تسجيل الدخول بدلًا من ذلك، فهو يتضمن عنوان الخادم.",
"server_subtitle": "أدخل العنوان الذي تستخدمه للوصول إلى بريد الويب.",
"server_title": "ما عنوان خادمك؟",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Quina és la vostra adreça electrònica?",
"email_step_title_add": "Quina és l'altra adreça?",
"email_title": "Utilitza la meva adreça electrònica",
"err_account_limit_detail": "Suprimiu un compte a la configuració per afegir-ne un altre.",
"err_bad_detail": "Comproveu el correu electrònic i la contrasenya. Si el vostre compte utilitza l'autenticació de dos factors, creeu una contrasenya d'aplicació al webmail i utilitzeu-la aquí.",
"err_bad_title": "No ha funcionat",
"err_cert_detail": "S'ha rebutjat el certificat de seguretat del servidor. Si és el vostre propi servidor, comproveu que el certificat sigui vàlid i no hagi caducat.",
"err_cert_title": "No s'ha pogut verificar {host}",
"err_code_expired_detail": "Els codis d'inici de sessió són vàlids durant uns minuts. Genereu-ne un de nou al webmail i torneu-lo a escanejar.",
"err_code_expired_detail": "Els codis d'inici de sessió són vàlids durant dos minuts. Mostreu-ne un de nou al webmail i torneu-lo a escanejar.",
"err_code_expired_or_used_detail": "Mostreu-ne un de nou al webmail i torneu-lo a escanejar.",
"err_code_expired_or_used_title": "Aquest codi ha caducat o ja s'ha utilitzat",
"err_code_expired_title": "Aquest codi ha caducat",
"err_code_invalid_detail": "Mostreu un codi nou al webmail (Configuració → Seguretat → Enllaça l'aplicació mòbil) i escanegeu-lo.",
"err_code_invalid_title": "Aquest codi no és vàlid",
"err_code_used_detail": "Cada codi només funciona una vegada. Mostreu-ne un de nou al webmail i torneu-lo a escanejar.",
"err_code_used_title": "Aquest codi ja s'ha utilitzat",
"err_generic_detail": "Alguna cosa ha anat malament. Torneu-ho a provar.",
"err_generic_title": "Ha fallat l'inici de sessió",
"err_interrupted_detail": "La resposta no coincideix amb la sol·licitud que vam iniciar. Torneu a iniciar la sessió.",
@@ -474,6 +481,17 @@
"err_no_server_title": "No hi ha cap servidor de correu a {host}",
"err_no_webmail_detail": "Aquest servidor no té webmail del Bulwark ni inici de sessió OAuth. Utilitzeu una contrasenya (o una contrasenya d'aplicació).",
"err_no_webmail_title": "No hi ha cap pàgina d'inici de sessió a {host}",
"err_pair_connect_detail": "Mostreu un codi nou a l'ordinador i torneu-lo a escanejar.",
"err_pair_connect_title": "Sessió iniciada, però no s'ha pogut connectar a {host}",
"err_pair_insecure_detail": "{host} vol utilitzar una connexió sense xifrar.",
"err_pair_network_detail": "Comproveu la connexió i que el telèfon pugui accedir al webmail, i torneu-ho a provar.",
"err_pair_rate_detail": "Espereu un minut i torneu-ho a provar.",
"err_pair_server_detail": "Torneu-ho a provar amb un codi nou. Si continua fallant, consulteu l'administrador.",
"err_pair_server_title": "El webmail no ha pogut completar l'inici de sessió",
"err_pair_unsupported_detail": "{host} no admet codis d'inici de sessió. Potser necessita una actualització, o l'enllaç apunta a una adreça incorrecta.",
"err_pair_unsupported_title": "Aquest webmail no pot enllaçar dispositius",
"err_pair_untrusted_detail": "{host} ha enviat dades d'inici de sessió per a un servidor que l'aplicació no pot verificar.",
"err_pair_untrusted_title": "No es pot confiar en aquest inici de sessió",
"err_rate_detail": "El servidor ens ha demanat que esperem un moment. Torneu-ho a provar d'aquí a poc.",
"err_rate_detail_seconds": "El servidor ens ha demanat que esperem. Torneu-ho a provar d'aquí a uns {seconds} segons.",
"err_rate_title": "Massa intents",
@@ -487,6 +505,12 @@
"know_server": "Conec l'adreça del meu servidor",
"known_server_desc": "Ja coneixem aquest servidor, així que no cal configurar res",
"known_server_title": "Un altre compte a {host}",
"link_confirm_connect": "Un enllaç demana iniciar la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.",
"link_confirm_connect_add": "Un enllaç demana afegir un compte iniciant la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.",
"link_confirm_connect_title": "Voleu obrir la pàgina d'inici de sessió de {host}?",
"link_confirm_pair": "Aquest enllaç inicia la sessió en un compte de {host}. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.",
"link_confirm_pair_add": "Aquest enllaç afegeix un compte de {host} i hi canvia l'aplicació. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.",
"link_confirm_title": "Voleu iniciar la sessió amb un enllaç?",
"looking_up": "S'està cercant el vostre servidor…",
"manual": "Introdueix les dades del servidor manualment",
"notice_bad_qr": "Aquest codi no és un codi d'inici de sessió del Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "S'està aparellant aquest dispositiu amb {host}…",
"password_subtitle": "La contrasenya només s'envia a aquest servidor i es desa al magatzem segur del dispositiu.",
"password_title": "Inicia la sessió a {host}",
"paste_button": "Enganxa",
"paste_clipboard_empty": "No hi ha res per enganxar. Primer copieu l'enllaç al webmail.",
"paste_hint": "Al webmail, obriu Configuració → Seguretat → Enllaça l'aplicació mòbil, copieu l'enllaç d'inici de sessió i enganxeu-lo aquí.",
"paste_invalid": "Això no és un enllaç d'inici de sessió del Bulwark",
"paste_label": "Enllaç d'inici de sessió",
"paste_title": "Enganxa l'enllaç d'inici de sessió",
"provider_detail": "L'escriureu a la seva pàgina. El Bulwark només desa un testimoni d'inici de sessió, que podeu revocar en qualsevol moment.",
"provider_title": "La contrasenya es queda amb el vostre proveïdor",
"qr_hint": "Configuració → Seguretat → Enllaça l'aplicació mòbil mostra un codi. Apunteu-hi la càmera.",
@@ -515,6 +545,7 @@
"server_hint": "La mateixa adreça que escriviu al navegador. Hi afegirem https://.",
"server_label": "Adreça del servidor",
"server_not_found": "No hem trobat cap servidor per a {domain}. Si l'allotgeu vosaltres, l'adreça és la que utilitzeu per al webmail.",
"server_paste_hint": "Teniu un enllaç d'inici de sessió? Enganxeu-lo aquí.",
"server_scan_hint": "Si no n'esteu segur, escanegeu un codi d'inici de sessió. Inclou l'adreça del servidor.",
"server_subtitle": "Introduïu l'adreça que utilitzeu per accedir al webmail.",
"server_title": "Quina és l'adreça del vostre servidor?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Jaká je vaše e-mailová adresa?",
"email_step_title_add": "Jaká je ta druhá adresa?",
"email_title": "Použít moji e-mailovou adresu",
"err_account_limit_detail": "Chcete-li přidat další účet, odeberte některý v Nastavení.",
"err_bad_detail": "Zkontrolujte e-mail a heslo. Pokud váš účet používá dvoufázové přihlášení, vytvořte ve webmailu heslo aplikace a použijte ho zde.",
"err_bad_title": "To nevyšlo",
"err_cert_detail": "Bezpečnostní certifikát serveru byl odmítnut. Pokud jde o váš vlastní server, zkontrolujte, zda je certifikát platný a nevypršel.",
"err_cert_title": "{host} nelze ověřit",
"err_code_expired_detail": "Přihlašovací kódy platí několik minut. Vygenerujte ve webmailu nový a naskenujte ho znovu.",
"err_code_expired_detail": "Přihlašovací kódy platí dvě minuty. Zobrazte ve webmailu nový a naskenujte ho znovu.",
"err_code_expired_or_used_detail": "Zobrazte ve webmailu nový a naskenujte ho znovu.",
"err_code_expired_or_used_title": "Platnost kódu vypršela nebo už byl použit",
"err_code_expired_title": "Platnost kódu vypršela",
"err_code_invalid_detail": "Zobrazte ve webmailu nový kód (Nastavení → Zabezpečení → Propojit mobilní aplikaci) a naskenujte ho.",
"err_code_invalid_title": "Tento kód není platný",
"err_code_used_detail": "Každý kód funguje jen jednou. Zobrazte ve webmailu nový a naskenujte ho znovu.",
"err_code_used_title": "Tento kód už byl použit",
"err_generic_detail": "Něco se pokazilo. Zkuste to znovu.",
"err_generic_title": "Přihlášení selhalo",
"err_interrupted_detail": "Odpověď neodpovídala zahájenému požadavku. Zkuste se přihlásit znovu.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Poštovní server nenalezen: {host}",
"err_no_webmail_detail": "Tento server nemá webmail Bulwark ani přihlášení přes OAuth. Použijte heslo (nebo heslo aplikace).",
"err_no_webmail_title": "Přihlašovací stránka nenalezena: {host}",
"err_pair_connect_detail": "Zobrazte na počítači nový kód a naskenujte ho znovu.",
"err_pair_connect_title": "Přihlášeno, ale nelze se připojit k {host}",
"err_pair_insecure_detail": "{host} chce použít nešifrované spojení.",
"err_pair_network_detail": "Zkontrolujte připojení a zda se telefon dostane k webmailu, a zkuste to znovu.",
"err_pair_rate_detail": "Počkejte minutu a zkuste to znovu.",
"err_pair_server_detail": "Zkuste to znovu s novým kódem. Pokud to stále selhává, obraťte se na správce.",
"err_pair_server_title": "Webmail nemohl dokončit přihlášení",
"err_pair_unsupported_detail": "{host} nepodporuje přihlašovací kódy. Možná potřebuje aktualizaci, nebo odkaz vede na špatnou adresu.",
"err_pair_unsupported_title": "Tento webmail neumí propojovat zařízení",
"err_pair_untrusted_detail": "{host} poslal přihlašovací údaje pro server, který aplikace nemůže ověřit.",
"err_pair_untrusted_title": "Tomuto přihlášení nelze důvěřovat",
"err_rate_detail": "Server nás požádal o chvíli strpení. Zkuste to brzy znovu.",
"err_rate_detail_seconds": "Server nás požádal o strpení. Zkuste to znovu přibližně za {seconds} s.",
"err_rate_title": "Příliš mnoho pokusů",
@@ -487,6 +505,12 @@
"know_server": "Znám adresu svého serveru",
"known_server_desc": "Tento server už známe, takže není třeba nic nastavovat",
"known_server_title": "Další účet na {host}",
"link_confirm_connect": "Odkaz žádá o přihlášení na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.",
"link_confirm_connect_add": "Odkaz žádá o přidání účtu přihlášením na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.",
"link_confirm_connect_title": "Otevřít přihlašovací stránku {host}?",
"link_confirm_pair": "Tento odkaz vás přihlásí k účtu z {host}. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.",
"link_confirm_pair_add": "Tento odkaz přidá účet z {host} a přepne na něj aplikaci. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.",
"link_confirm_title": "Přihlásit se pomocí odkazu?",
"looking_up": "Hledání vašeho serveru…",
"manual": "Zadat údaje serveru ručně",
"notice_bad_qr": "Toto není přihlašovací kód Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Párování tohoto zařízení: {host}…",
"password_subtitle": "Heslo se odešle pouze tomuto serveru a uloží se do úložiště klíčů v zařízení.",
"password_title": "Přihlásit se k {host}",
"paste_button": "Vložit",
"paste_clipboard_empty": "Není co vložit. Nejprve zkopírujte odkaz ve webmailu.",
"paste_hint": "Ve webmailu otevřete Nastavení → Zabezpečení → Propojit mobilní aplikaci, zkopírujte přihlašovací odkaz a vložte ho sem.",
"paste_invalid": "Toto není přihlašovací odkaz Bulwark",
"paste_label": "Přihlašovací odkaz",
"paste_title": "Vložit přihlašovací odkaz",
"provider_detail": "Zadáte ho na jeho stránce. Bulwark ukládá pouze přihlašovací token, který můžete kdykoli odvolat.",
"provider_title": "Vaše heslo zůstává u poskytovatele",
"qr_hint": "V Nastavení → Zabezpečení → Propojit mobilní aplikaci se zobrazí kód. Namiřte na něj fotoaparát.",
@@ -515,6 +545,7 @@
"server_hint": "Stejná adresa, jakou zadáváte v prohlížeči. https:// doplníme za vás.",
"server_label": "Adresa serveru",
"server_not_found": "Pro {domain} jsme nenašli žádný server. Pokud provozujete vlastní server, zadejte adresu, kterou používáte pro webmail.",
"server_paste_hint": "Máte místo toho přihlašovací odkaz? Vložte ho sem.",
"server_scan_hint": "Pokud si nejste jisti, naskenujte raději přihlašovací kód. Obsahuje adresu serveru.",
"server_subtitle": "Zadejte adresu, přes kterou otevíráte webmail.",
"server_title": "Jaká je adresa vašeho serveru?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Hvad er din e-mailadresse?",
"email_step_title_add": "Hvad er den anden adresse?",
"email_title": "Brug min e-mailadresse",
"err_account_limit_detail": "Fjern en konto i Indstillinger for at tilføje en ny.",
"err_bad_detail": "Tjek din e-mail og adgangskode. Hvis din konto bruger to-faktor-godkendelse, så opret en app-adgangskode i webmailen, og brug den her.",
"err_bad_title": "Det virkede ikke",
"err_cert_detail": "Serverens sikkerhedscertifikat blev afvist. Hvis det er din egen server, så tjek, at certifikatet er gyldigt og ikke udløbet.",
"err_cert_title": "Kunne ikke verificere {host}",
"err_code_expired_detail": "Loginkoder er kun gyldige i nogle få minutter. Generér en ny i webmailen, og scan igen.",
"err_code_expired_detail": "Loginkoder er gyldige i to minutter. Vis en ny i webmailen, og scan igen.",
"err_code_expired_or_used_detail": "Vis en ny i webmailen, og scan igen.",
"err_code_expired_or_used_title": "Den kode er udløbet eller allerede brugt",
"err_code_expired_title": "Koden er udløbet",
"err_code_invalid_detail": "Vis en ny kode i webmailen (Indstillinger → Sikkerhed → Tilknyt mobilapp), og scan den.",
"err_code_invalid_title": "Den kode er ikke gyldig",
"err_code_used_detail": "Hver kode virker kun én gang. Vis en ny i webmailen, og scan igen.",
"err_code_used_title": "Den kode er allerede brugt",
"err_generic_detail": "Noget gik galt. Prøv igen.",
"err_generic_title": "Login mislykkedes",
"err_interrupted_detail": "Svaret matchede ikke den anmodning, vi startede. Prøv at logge ind igen.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Ingen mailserver på {host}",
"err_no_webmail_detail": "Denne server har hverken Bulwark-webmail eller OAuth-login. Brug i stedet en adgangskode (eller en app-adgangskode).",
"err_no_webmail_title": "Ingen login-side på {host}",
"err_pair_connect_detail": "Vis en ny kode på din computer, og scan igen.",
"err_pair_connect_title": "Logget ind, men kunne ikke oprette forbindelse til {host}",
"err_pair_insecure_detail": "{host} vil bruge en ukrypteret forbindelse.",
"err_pair_network_detail": "Tjek din forbindelse, og at din telefon kan nå webmailen, og prøv så igen.",
"err_pair_rate_detail": "Vent et minut, og prøv så igen.",
"err_pair_server_detail": "Prøv igen med en ny kode. Hvis det bliver ved med at mislykkes, så kontakt din administrator.",
"err_pair_server_title": "Webmailen kunne ikke gennemføre login",
"err_pair_unsupported_detail": "{host} understøtter ikke loginkoder. Den skal måske opdateres, eller linket peger på den forkerte adresse.",
"err_pair_unsupported_title": "Denne webmail kan ikke tilknytte enheder",
"err_pair_untrusted_detail": "{host} sendte loginoplysninger til en server, som appen ikke kan bekræfte.",
"err_pair_untrusted_title": "Dette login kan ikke betros",
"err_rate_detail": "Serveren bad os vente et øjeblik. Prøv igen om lidt.",
"err_rate_detail_seconds": "Serveren bad os vente. Prøv igen om cirka {seconds} sekunder.",
"err_rate_title": "For mange forsøg",
@@ -487,6 +505,12 @@
"know_server": "Jeg kender min serveradresse",
"known_server_desc": "Vi kender allerede denne server, så der skal ikke konfigureres noget",
"known_server_title": "En anden konto på {host}",
"link_confirm_connect": "Et link beder om at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.",
"link_confirm_connect_add": "Et link beder om at tilføje en konto ved at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.",
"link_confirm_connect_title": "Åbn login-siden for {host}?",
"link_confirm_pair": "Dette link logger ind på en konto fra {host}. Fortsæt kun, hvis du lige har vist en loginkode på din computer.",
"link_confirm_pair_add": "Dette link tilføjer en konto fra {host} og skifter appen til den. Fortsæt kun, hvis du lige har vist en loginkode på din computer.",
"link_confirm_title": "Log ind med et link?",
"looking_up": "Slår din server op…",
"manual": "Indtast serveroplysninger manuelt",
"notice_bad_qr": "Den kode er ikke en Bulwark-loginkode",
@@ -505,6 +529,12 @@
"pairing_detail": "Parrer denne enhed med {host}…",
"password_subtitle": "Din adgangskode sendes kun til denne server og gemmes i enhedens nøglering.",
"password_title": "Log ind på {host}",
"paste_button": "Indsæt",
"paste_clipboard_empty": "Der er intet at indsætte. Kopiér først linket i webmailen.",
"paste_hint": "Åbn Indstillinger → Sikkerhed → Tilknyt mobilapp i webmailen, kopiér loginlinket, og indsæt det her.",
"paste_invalid": "Det er ikke et Bulwark-loginlink",
"paste_label": "Loginlink",
"paste_title": "Indsæt loginlink",
"provider_detail": "Du indtaster den på deres side. Bulwark gemmer kun et login-token, som du til enhver tid kan tilbagekalde.",
"provider_title": "Din adgangskode bliver hos din udbyder",
"qr_hint": "Indstillinger → Sikkerhed → Tilknyt mobilapp viser en kode. Ret kameraet mod den.",
@@ -515,6 +545,7 @@
"server_hint": "Samme adresse, som du skriver i browseren. Vi tilføjer https:// for dig.",
"server_label": "Serveradresse",
"server_not_found": "Vi kunne ikke finde en server for {domain}. Hvis du selv hoster, er det den adresse, du bruger til webmail.",
"server_paste_hint": "Har du i stedet et loginlink? Indsæt det her.",
"server_scan_hint": "Hvis du er i tvivl, så scan i stedet en loginkode. Den indeholder serveradressen.",
"server_subtitle": "Indtast den adresse, du bruger til at nå din webmail.",
"server_title": "Hvad er din serveradresse?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Wie lautet Ihre E-Mail-Adresse?",
"email_step_title_add": "Wie lautet die andere Adresse?",
"email_title": "Meine E-Mail-Adresse verwenden",
"err_account_limit_detail": "Entfernen Sie in den Einstellungen ein Konto, um ein weiteres hinzuzufügen.",
"err_bad_detail": "Prüfen Sie E-Mail-Adresse und Passwort. Wenn Ihr Konto die Zwei-Faktor-Anmeldung nutzt, erstellen Sie im Webmail ein App-Passwort und verwenden Sie es hier.",
"err_bad_title": "Das hat nicht funktioniert",
"err_cert_detail": "Das Sicherheitszertifikat des Servers wurde abgelehnt. Wenn dies Ihr eigener Server ist, prüfen Sie, ob das Zertifikat gültig und nicht abgelaufen ist.",
"err_cert_title": "Überprüfung von {host} fehlgeschlagen",
"err_code_expired_detail": "Anmeldecodes sind nur wenige Minuten gültig. Erzeugen Sie im Webmail einen neuen und scannen Sie erneut.",
"err_code_expired_detail": "Anmeldecodes sind zwei Minuten gültig. Zeigen Sie im Webmail einen neuen an und scannen Sie erneut.",
"err_code_expired_or_used_detail": "Zeigen Sie im Webmail einen neuen an und scannen Sie erneut.",
"err_code_expired_or_used_title": "Dieser Code ist abgelaufen oder wurde bereits verwendet",
"err_code_expired_title": "Dieser Code ist abgelaufen",
"err_code_invalid_detail": "Zeigen Sie im Webmail einen neuen Code an (Einstellungen → Sicherheit → Mobile App verknüpfen) und scannen Sie ihn.",
"err_code_invalid_title": "Dieser Code ist ungültig",
"err_code_used_detail": "Jeder Code funktioniert nur einmal. Zeigen Sie im Webmail einen neuen an und scannen Sie erneut.",
"err_code_used_title": "Dieser Code wurde bereits verwendet",
"err_generic_detail": "Etwas ist schiefgelaufen. Versuchen Sie es erneut.",
"err_generic_title": "Anmeldung fehlgeschlagen",
"err_interrupted_detail": "Die Antwort passte nicht zur gestarteten Anfrage. Melden Sie sich erneut an.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Kein Mailserver bei {host}",
"err_no_webmail_detail": "Dieser Server hat weder ein Bulwark-Webmail noch eine OAuth-Anmeldung. Verwenden Sie stattdessen ein Passwort (oder ein App-Passwort).",
"err_no_webmail_title": "Keine Anmeldeseite bei {host}",
"err_pair_connect_detail": "Zeigen Sie auf Ihrem Computer einen neuen Code an und scannen Sie erneut.",
"err_pair_connect_title": "Angemeldet, aber keine Verbindung zu {host}",
"err_pair_insecure_detail": "{host} möchte eine unverschlüsselte Verbindung verwenden.",
"err_pair_network_detail": "Prüfen Sie Ihre Verbindung und ob Ihr Telefon das Webmail erreichen kann, und versuchen Sie es dann erneut.",
"err_pair_rate_detail": "Warten Sie eine Minute und versuchen Sie es dann erneut.",
"err_pair_server_detail": "Versuchen Sie es mit einem neuen Code erneut. Wenn es weiterhin fehlschlägt, wenden Sie sich an Ihren Administrator.",
"err_pair_server_title": "Das Webmail konnte die Anmeldung nicht abschließen",
"err_pair_unsupported_detail": "{host} unterstützt keine Anmeldecodes. Eventuell ist ein Update nötig, oder der Link verweist auf die falsche Adresse.",
"err_pair_unsupported_title": "Dieses Webmail kann keine Geräte verknüpfen",
"err_pair_untrusted_detail": "{host} hat Anmeldedaten für einen Server gesendet, den die App nicht überprüfen kann.",
"err_pair_untrusted_title": "Dieser Anmeldung kann nicht vertraut werden",
"err_rate_detail": "Der Server hat uns gebeten, kurz zu warten. Versuchen Sie es gleich noch einmal.",
"err_rate_detail_seconds": "Der Server hat uns gebeten zu warten. Versuchen Sie es in etwa {seconds} Sekunden erneut.",
"err_rate_title": "Zu viele Versuche",
@@ -487,6 +505,12 @@
"know_server": "Ich kenne meine Serveradresse",
"known_server_desc": "Diesen Server kennen wir bereits, daher ist keine Einrichtung nötig",
"known_server_title": "Weiteres Konto auf {host}",
"link_confirm_connect": "Ein Link möchte Sie auf dieser Seite anmelden. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.",
"link_confirm_connect_add": "Ein Link möchte über die Anmeldung auf dieser Seite ein Konto hinzufügen. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.",
"link_confirm_connect_title": "Anmeldeseite von {host} öffnen?",
"link_confirm_pair": "Dieser Link meldet Sie bei einem Konto von {host} an. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.",
"link_confirm_pair_add": "Dieser Link fügt ein Konto von {host} hinzu und wechselt die App zu diesem Konto. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.",
"link_confirm_title": "Mit einem Link anmelden?",
"looking_up": "Ihr Server wird gesucht…",
"manual": "Serverdaten manuell eingeben",
"notice_bad_qr": "Das ist kein Bulwark-Anmeldecode",
@@ -505,6 +529,12 @@
"pairing_detail": "Dieses Gerät wird mit {host} gekoppelt…",
"password_subtitle": "Ihr Passwort wird nur an diesen Server gesendet und im Schlüsselbund des Geräts gespeichert.",
"password_title": "Bei {host} anmelden",
"paste_button": "Einfügen",
"paste_clipboard_empty": "Nichts zum Einfügen. Kopieren Sie zuerst den Link im Webmail.",
"paste_hint": "Öffnen Sie im Webmail Einstellungen → Sicherheit → Mobile App verknüpfen, kopieren Sie den Anmeldelink und fügen Sie ihn hier ein.",
"paste_invalid": "Das ist kein Bulwark-Anmeldelink",
"paste_label": "Anmeldelink",
"paste_title": "Anmeldelink einfügen",
"provider_detail": "Sie geben es auf dessen Seite ein. Bulwark speichert nur ein Anmeldetoken, das Sie jederzeit widerrufen können.",
"provider_title": "Ihr Passwort bleibt bei Ihrem Anbieter",
"qr_hint": "Einstellungen → Sicherheit → Mobile App verknüpfen zeigt einen Code an. Richten Sie die Kamera darauf.",
@@ -515,6 +545,7 @@
"server_hint": "Dieselbe Adresse, die Sie im Browser eingeben. https:// ergänzen wir für Sie.",
"server_label": "Serveradresse",
"server_not_found": "Für {domain} wurde kein Server gefunden. Wenn Sie selbst hosten, ist es die Adresse, die Sie für das Webmail verwenden.",
"server_paste_hint": "Haben Sie stattdessen einen Anmeldelink? Fügen Sie ihn hier ein.",
"server_scan_hint": "Wenn Sie unsicher sind, scannen Sie stattdessen einen Anmeldecode. Er enthält die Serveradresse.",
"server_subtitle": "Geben Sie die Adresse ein, unter der Sie Ihr Webmail erreichen.",
"server_title": "Wie lautet Ihre Serveradresse?",
+32 -1
View File
@@ -470,12 +470,19 @@
"email_step_title": "What's your email address?",
"email_step_title_add": "What's the other address?",
"email_title": "Use my email address",
"err_account_limit_detail": "Remove an account in Settings to add another one.",
"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_detail": "Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.",
"err_code_expired_or_used_detail": "Show a new one in the webmail and scan again.",
"err_code_expired_or_used_title": "That code has expired or was already used",
"err_code_expired_title": "That code has expired",
"err_code_invalid_detail": "Show a new code in the webmail (Settings → Security → Link Mobile App) and scan it.",
"err_code_invalid_title": "That code isn't valid",
"err_code_used_detail": "Each code works once. Show a new one in the webmail and scan again.",
"err_code_used_title": "That code was already used",
"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.",
@@ -484,6 +491,17 @@
"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_pair_connect_detail": "Show a new code on your computer and scan again.",
"err_pair_connect_title": "Signed in, but couldn't connect to {host}",
"err_pair_insecure_detail": "{host} wants to use an unencrypted connection.",
"err_pair_network_detail": "Check your connection and that your phone can reach the webmail, then try again.",
"err_pair_rate_detail": "Wait a minute, then try again.",
"err_pair_server_detail": "Try again with a new code. If it keeps failing, ask your administrator.",
"err_pair_server_title": "The webmail couldn't complete the sign-in",
"err_pair_unsupported_detail": "{host} doesn't support sign-in codes. It may need an update, or the link points to the wrong address.",
"err_pair_unsupported_title": "This webmail can't link devices",
"err_pair_untrusted_detail": "{host} sent sign-in details for a server the app can't verify.",
"err_pair_untrusted_title": "This sign-in can't be trusted",
"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",
@@ -497,6 +515,12 @@
"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}",
"link_confirm_connect": "A link asks to sign in on this page. Only continue if you use {host} for your mail.",
"link_confirm_connect_add": "A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.",
"link_confirm_connect_title": "Open the sign-in page of {host}?",
"link_confirm_pair": "This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.",
"link_confirm_pair_add": "This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.",
"link_confirm_title": "Sign in with a link?",
"looking_up": "Looking up your server…",
"manual": "Enter server details manually",
"notice_bad_qr": "That code isn't a Bulwark sign-in code",
@@ -515,6 +539,12 @@
"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}",
"paste_button": "Paste",
"paste_clipboard_empty": "Nothing to paste. Copy the link in the webmail first.",
"paste_hint": "In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.",
"paste_invalid": "That isn't a Bulwark sign-in link",
"paste_label": "Sign-in link",
"paste_title": "Paste sign-in link",
"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 Mobile App shows a code. Point the camera at it.",
@@ -525,6 +555,7 @@
"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_paste_hint": "Have a sign-in link instead? Paste it here.",
"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?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "¿Cuál es tu dirección de correo?",
"email_step_title_add": "¿Cuál es la otra dirección?",
"email_title": "Usar mi dirección de correo",
"err_account_limit_detail": "Elimina una cuenta en Ajustes para añadir otra.",
"err_bad_detail": "Comprueba tu correo y tu contraseña. Si tu cuenta usa autenticación de dos factores, crea una contraseña de aplicación en el webmail y úsala aquí.",
"err_bad_title": "No ha funcionado",
"err_cert_detail": "Se rechazó el certificado de seguridad del servidor. Si es tu propio servidor, comprueba que el certificado sea válido y no haya caducado.",
"err_cert_title": "No se pudo verificar {host}",
"err_code_expired_detail": "Los códigos de inicio de sesión son válidos durante unos minutos. Genera uno nuevo en el webmail y vuelve a escanearlo.",
"err_code_expired_detail": "Los códigos de inicio de sesión son válidos durante dos minutos. Muestra uno nuevo en el webmail y vuelve a escanearlo.",
"err_code_expired_or_used_detail": "Muestra uno nuevo en el webmail y vuelve a escanearlo.",
"err_code_expired_or_used_title": "Ese código ha caducado o ya se ha usado",
"err_code_expired_title": "Ese código ha caducado",
"err_code_invalid_detail": "Muestra un código nuevo en el webmail (Configuración → Seguridad → Vincular aplicación móvil) y escanéalo.",
"err_code_invalid_title": "Ese código no es válido",
"err_code_used_detail": "Cada código funciona una sola vez. Muestra uno nuevo en el webmail y vuelve a escanearlo.",
"err_code_used_title": "Ese código ya se ha usado",
"err_generic_detail": "Algo salió mal. Inténtalo de nuevo.",
"err_generic_title": "Error al iniciar sesión",
"err_interrupted_detail": "La respuesta no coincide con la solicitud que iniciamos. Vuelve a iniciar sesión.",
@@ -474,6 +481,17 @@
"err_no_server_title": "No hay ningún servidor de correo en {host}",
"err_no_webmail_detail": "Este servidor no tiene webmail de Bulwark ni inicio de sesión OAuth. Usa una contraseña (o una contraseña de aplicación).",
"err_no_webmail_title": "No hay página de inicio de sesión en {host}",
"err_pair_connect_detail": "Muestra un código nuevo en tu ordenador y vuelve a escanearlo.",
"err_pair_connect_title": "Sesión iniciada, pero no se pudo conectar con {host}",
"err_pair_insecure_detail": "{host} quiere usar una conexión sin cifrar.",
"err_pair_network_detail": "Comprueba tu conexión y que tu teléfono pueda acceder al webmail, y vuelve a intentarlo.",
"err_pair_rate_detail": "Espera un minuto y vuelve a intentarlo.",
"err_pair_server_detail": "Vuelve a intentarlo con un código nuevo. Si sigue fallando, consulta a tu administrador.",
"err_pair_server_title": "El webmail no ha podido completar el inicio de sesión",
"err_pair_unsupported_detail": "{host} no admite códigos de inicio de sesión. Puede que necesite una actualización o que el enlace apunte a una dirección incorrecta.",
"err_pair_unsupported_title": "Este webmail no puede vincular dispositivos",
"err_pair_untrusted_detail": "{host} ha enviado datos de inicio de sesión para un servidor que la aplicación no puede verificar.",
"err_pair_untrusted_title": "No se puede confiar en este inicio de sesión",
"err_rate_detail": "El servidor nos pidió esperar un momento. Inténtalo de nuevo en breve.",
"err_rate_detail_seconds": "El servidor nos pidió esperar. Inténtalo de nuevo en unos {seconds} segundos.",
"err_rate_title": "Demasiados intentos",
@@ -487,6 +505,12 @@
"know_server": "Conozco la dirección de mi servidor",
"known_server_desc": "Ya conocemos este servidor, así que no hay nada que configurar",
"known_server_title": "Otra cuenta en {host}",
"link_confirm_connect": "Un enlace pide iniciar sesión en esta página. Continúa solo si usas {host} para tu correo.",
"link_confirm_connect_add": "Un enlace pide añadir una cuenta iniciando sesión en esta página. Continúa solo si usas {host} para tu correo.",
"link_confirm_connect_title": "¿Abrir la página de inicio de sesión de {host}?",
"link_confirm_pair": "Este enlace inicia sesión en una cuenta de {host}. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.",
"link_confirm_pair_add": "Este enlace añade una cuenta de {host} y cambia la aplicación a ella. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.",
"link_confirm_title": "¿Iniciar sesión con un enlace?",
"looking_up": "Buscando tu servidor…",
"manual": "Introducir los datos del servidor manualmente",
"notice_bad_qr": "Ese código no es un código de inicio de sesión de Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Vinculando este dispositivo con {host}…",
"password_subtitle": "Tu contraseña solo se envía a este servidor y se guarda en el almacén seguro del dispositivo.",
"password_title": "Iniciar sesión en {host}",
"paste_button": "Pegar",
"paste_clipboard_empty": "No hay nada que pegar. Copia primero el enlace en el webmail.",
"paste_hint": "En el webmail, abre Configuración → Seguridad → Vincular aplicación móvil, copia el enlace de inicio de sesión y pégalo aquí.",
"paste_invalid": "Eso no es un enlace de inicio de sesión de Bulwark",
"paste_label": "Enlace de inicio de sesión",
"paste_title": "Pegar enlace de inicio de sesión",
"provider_detail": "La escribirás en su página. Bulwark solo guarda un token de inicio de sesión, que puedes revocar en cualquier momento.",
"provider_title": "Tu contraseña se queda con tu proveedor",
"qr_hint": "Configuración → Seguridad → Vincular aplicación móvil muestra un código. Apunta la cámara hacia él.",
@@ -515,6 +545,7 @@
"server_hint": "La misma dirección que escribes en el navegador. Añadiremos https:// por ti.",
"server_label": "Dirección del servidor",
"server_not_found": "No encontramos ningún servidor para {domain}. Si lo alojas tú, la dirección es la que usas para el webmail.",
"server_paste_hint": "¿Tienes un enlace de inicio de sesión? Pégalo aquí.",
"server_scan_hint": "Si no estás seguro, escanea un código de inicio de sesión. Incluye la dirección del servidor.",
"server_subtitle": "Introduce la dirección que usas para acceder a tu webmail.",
"server_title": "¿Cuál es la dirección de tu servidor?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "نشانی ایمیل شما چیست؟",
"email_step_title_add": "نشانی دیگر چیست؟",
"email_title": "استفاده از نشانی ایمیل من",
"err_account_limit_detail": "برای افزودن حساب دیگر، یکی از حساب‌ها را در تنظیمات حذف کنید.",
"err_bad_detail": "ایمیل و رمز عبور خود را بررسی کنید. اگر حساب شما از ورود دو مرحله‌ای استفاده می‌کند، در ایمیل تحت وب یک رمز برنامه بسازید و آن را اینجا وارد کنید.",
"err_bad_title": "موفق نشد",
"err_cert_detail": "گواهی امنیتی سرور رد شد. اگر این سرور خودتان است، بررسی کنید گواهی معتبر باشد و منقضی نشده باشد.",
"err_cert_title": "تأیید {host} ممکن نشد",
"err_code_expired_detail": "کدهای ورود فقط چند دقیقه اعتبار دارند. در ایمیل تحت وب کد تازه‌ای بسازید و دوباره اسکن کنید.",
"err_code_expired_detail": "کدهای ورود دو دقیقه اعتبار دارند. در ایمیل تحت وب کد تازه‌ای نمایش دهید و دوباره اسکن کنید.",
"err_code_expired_or_used_detail": "در ایمیل تحت وب کد تازه‌ای نمایش دهید و دوباره اسکن کنید.",
"err_code_expired_or_used_title": "این کد منقضی شده یا قبلاً استفاده شده است",
"err_code_expired_title": "این کد منقضی شده است",
"err_code_invalid_detail": "در ایمیل تحت وب کد تازه‌ای نمایش دهید (تنظیمات ← امنیت ← اتصال موبایل) و آن را اسکن کنید.",
"err_code_invalid_title": "این کد معتبر نیست",
"err_code_used_detail": "هر کد فقط یک بار کار می‌کند. در ایمیل تحت وب کد تازه‌ای نمایش دهید و دوباره اسکن کنید.",
"err_code_used_title": "این کد قبلاً استفاده شده است",
"err_generic_detail": "مشکلی پیش آمد. دوباره امتحان کنید.",
"err_generic_title": "ورود ناموفق بود",
"err_interrupted_detail": "پاسخ با درخواستی که آغاز کردیم مطابقت نداشت. دوباره وارد شوید.",
@@ -474,6 +481,17 @@
"err_no_server_title": "سرور ایمیلی در {host} نیست",
"err_no_webmail_detail": "این سرور نه ایمیل تحت وب Bulwark دارد و نه ورود با OAuth. به‌جای آن از رمز عبور (یا رمز برنامه) استفاده کنید.",
"err_no_webmail_title": "صفحه ورودی در {host} نیست",
"err_pair_connect_detail": "روی رایانه‌تان کد تازه‌ای نمایش دهید و دوباره اسکن کنید.",
"err_pair_connect_title": "وارد شدید، اما اتصال به {host} ممکن نشد",
"err_pair_insecure_detail": "{host} می‌خواهد از اتصال رمزگذاری‌نشده استفاده کند.",
"err_pair_network_detail": "اتصال خود را بررسی کنید و مطمئن شوید تلفنتان به ایمیل تحت وب دسترسی دارد، سپس دوباره امتحان کنید.",
"err_pair_rate_detail": "یک دقیقه صبر کنید، سپس دوباره امتحان کنید.",
"err_pair_server_detail": "با یک کد تازه دوباره امتحان کنید. اگر باز هم ناموفق بود، با مدیر سیستم تماس بگیرید.",
"err_pair_server_title": "ایمیل تحت وب نتوانست ورود را کامل کند",
"err_pair_unsupported_detail": "{host} از کدهای ورود پشتیبانی نمی‌کند. شاید نیاز به به‌روزرسانی داشته باشد، یا پیوند به نشانی اشتباهی اشاره می‌کند.",
"err_pair_unsupported_title": "این ایمیل تحت وب نمی‌تواند دستگاه‌ها را متصل کند",
"err_pair_untrusted_detail": "{host} اطلاعات ورود را برای سروری فرستاده که برنامه نمی‌تواند آن را تأیید کند.",
"err_pair_untrusted_title": "به این ورود نمی‌توان اعتماد کرد",
"err_rate_detail": "سرور از ما خواست کمی صبر کنیم. کمی بعد دوباره امتحان کنید.",
"err_rate_detail_seconds": "سرور از ما خواست صبر کنیم. حدود {seconds} ثانیه دیگر دوباره امتحان کنید.",
"err_rate_title": "تلاش‌های بیش از حد",
@@ -487,6 +505,12 @@
"know_server": "نشانی سرورم را می‌دانم",
"known_server_desc": "این سرور را از قبل می‌شناسیم، پس نیازی به راه‌اندازی نیست",
"known_server_title": "حسابی دیگر در {host}",
"link_confirm_connect": "یک پیوند می‌خواهد در این صفحه وارد شوید. فقط اگر برای ایمیل‌تان از {host} استفاده می‌کنید ادامه دهید.",
"link_confirm_connect_add": "یک پیوند می‌خواهد با ورود در این صفحه حسابی اضافه کنید. فقط اگر برای ایمیل‌تان از {host} استفاده می‌کنید ادامه دهید.",
"link_confirm_connect_title": "صفحه ورود {host} باز شود؟",
"link_confirm_pair": "این پیوند شما را به حسابی از {host} وارد می‌کند. فقط اگر همین حالا روی رایانه‌تان کد ورود نمایش داده‌اید ادامه دهید.",
"link_confirm_pair_add": "این پیوند حسابی از {host} را اضافه می‌کند و برنامه را به آن حساب می‌برد. فقط اگر همین حالا روی رایانه‌تان کد ورود نمایش داده‌اید ادامه دهید.",
"link_confirm_title": "با پیوند وارد می‌شوید؟",
"looking_up": "در حال جستجوی سرور شما…",
"manual": "وارد کردن دستی مشخصات سرور",
"notice_bad_qr": "این کد، کد ورود Bulwark نیست",
@@ -505,6 +529,12 @@
"pairing_detail": "در حال جفت کردن این دستگاه با {host}…",
"password_subtitle": "رمز عبور شما فقط به این سرور فرستاده می‌شود و در انبار امن دستگاه ذخیره می‌شود.",
"password_title": "ورود به {host}",
"paste_button": "چسباندن",
"paste_clipboard_empty": "چیزی برای چسباندن نیست. ابتدا پیوند را در ایمیل تحت وب کپی کنید.",
"paste_hint": "در ایمیل تحت وب، تنظیمات ← امنیت ← اتصال موبایل را باز کنید، پیوند ورود را کپی کنید و اینجا بچسبانید.",
"paste_invalid": "این پیوند ورود Bulwark نیست",
"paste_label": "پیوند ورود",
"paste_title": "چسباندن پیوند ورود",
"provider_detail": "آن را در صفحه آن‌ها وارد می‌کنید. Bulwark فقط یک توکن ورود نگه می‌دارد که هر زمان بخواهید می‌توانید باطلش کنید.",
"provider_title": "رمز عبور شما نزد ارائه‌دهنده‌تان می‌ماند",
"qr_hint": "در تنظیمات ← امنیت ← اتصال موبایل یک کد نمایش داده می‌شود. دوربین را به سمت آن بگیرید.",
@@ -515,6 +545,7 @@
"server_hint": "همان نشانی‌ای که در مرورگر تایپ می‌کنید. https:// را خودمان اضافه می‌کنیم.",
"server_label": "نشانی سرور",
"server_not_found": "سروری برای {domain} پیدا نکردیم. اگر سرور خودتان را میزبانی می‌کنید، نشانی همان است که برای ایمیل تحت وب استفاده می‌کنید.",
"server_paste_hint": "به‌جای آن پیوند ورود دارید؟ اینجا بچسبانید.",
"server_scan_hint": "اگر مطمئن نیستید، به‌جای آن کد ورود را اسکن کنید. نشانی سرور در آن هست.",
"server_subtitle": "نشانی‌ای را که با آن به ایمیل تحت وب دسترسی دارید وارد کنید.",
"server_title": "نشانی سرور شما چیست؟",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Quelle est votre adresse e-mail ?",
"email_step_title_add": "Quelle est l'autre adresse ?",
"email_title": "Utiliser mon adresse e-mail",
"err_account_limit_detail": "Supprimez un compte dans les Paramètres pour en ajouter un autre.",
"err_bad_detail": "Vérifiez votre adresse e-mail et votre mot de passe. Si votre compte utilise l'authentification à deux facteurs, créez un mot de passe d'application dans le webmail et utilisez-le ici.",
"err_bad_title": "Cela n'a pas fonctionné",
"err_cert_detail": "Le certificat de sécurité du serveur a été refusé. S'il s'agit de votre propre serveur, vérifiez que le certificat est valide et n'a pas expiré.",
"err_cert_title": "Impossible de vérifier {host}",
"err_code_expired_detail": "Les codes de connexion ne sont valables que quelques minutes. Générez-en un nouveau dans le webmail et scannez-le à nouveau.",
"err_code_expired_detail": "Les codes de connexion sont valables deux minutes. Affichez-en un nouveau dans le webmail et scannez-le à nouveau.",
"err_code_expired_or_used_detail": "Affichez-en un nouveau dans le webmail et scannez-le à nouveau.",
"err_code_expired_or_used_title": "Ce code a expiré ou a déjà été utilisé",
"err_code_expired_title": "Ce code a expiré",
"err_code_invalid_detail": "Affichez un nouveau code dans le webmail (Paramètres → Sécurité → Associer l'application mobile) et scannez-le.",
"err_code_invalid_title": "Ce code n'est pas valide",
"err_code_used_detail": "Chaque code ne fonctionne qu'une fois. Affichez-en un nouveau dans le webmail et scannez-le à nouveau.",
"err_code_used_title": "Ce code a déjà été utilisé",
"err_generic_detail": "Un problème est survenu. Réessayez.",
"err_generic_title": "Échec de la connexion",
"err_interrupted_detail": "La réponse ne correspondait pas à la requête que nous avions lancée. Essayez de vous reconnecter.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Aucun serveur de messagerie sur {host}",
"err_no_webmail_detail": "Ce serveur n'a ni webmail Bulwark ni connexion OAuth. Utilisez plutôt un mot de passe (ou un mot de passe d'application).",
"err_no_webmail_title": "Aucune page de connexion sur {host}",
"err_pair_connect_detail": "Affichez un nouveau code sur votre ordinateur et scannez-le à nouveau.",
"err_pair_connect_title": "Connecté, mais impossible de joindre {host}",
"err_pair_insecure_detail": "{host} veut utiliser une connexion non chiffrée.",
"err_pair_network_detail": "Vérifiez votre connexion et que votre téléphone peut accéder au webmail, puis réessayez.",
"err_pair_rate_detail": "Patientez une minute, puis réessayez.",
"err_pair_server_detail": "Réessayez avec un nouveau code. Si le problème persiste, contactez votre administrateur.",
"err_pair_server_title": "Le webmail n'a pas pu terminer la connexion",
"err_pair_unsupported_detail": "{host} ne prend pas en charge les codes de connexion. Il a peut-être besoin d'une mise à jour, ou le lien pointe vers la mauvaise adresse.",
"err_pair_unsupported_title": "Ce webmail ne peut pas associer d'appareils",
"err_pair_untrusted_detail": "{host} a envoyé des informations de connexion pour un serveur que l'application ne peut pas vérifier.",
"err_pair_untrusted_title": "Cette connexion n'est pas fiable",
"err_rate_detail": "Le serveur nous a demandé de patienter un instant. Réessayez dans un moment.",
"err_rate_detail_seconds": "Le serveur nous a demandé de patienter. Réessayez dans environ {seconds} secondes.",
"err_rate_title": "Trop de tentatives",
@@ -487,6 +505,12 @@
"know_server": "Je connais l'adresse de mon serveur",
"known_server_desc": "Nous connaissons déjà ce serveur, aucune configuration n'est donc nécessaire",
"known_server_title": "Un autre compte sur {host}",
"link_confirm_connect": "Un lien demande à vous connecter sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.",
"link_confirm_connect_add": "Un lien demande à ajouter un compte en vous connectant sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.",
"link_confirm_connect_title": "Ouvrir la page de connexion de {host} ?",
"link_confirm_pair": "Ce lien vous connecte à un compte de {host}. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.",
"link_confirm_pair_add": "Ce lien ajoute un compte de {host} et fait basculer l'application sur ce compte. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.",
"link_confirm_title": "Se connecter avec un lien ?",
"looking_up": "Recherche de votre serveur…",
"manual": "Saisir les informations du serveur manuellement",
"notice_bad_qr": "Ce code n'est pas un code de connexion Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Association de cet appareil avec {host}…",
"password_subtitle": "Votre mot de passe n'est envoyé qu'à ce serveur et est stocké dans le trousseau de l'appareil.",
"password_title": "Se connecter à {host}",
"paste_button": "Coller",
"paste_clipboard_empty": "Rien à coller. Copiez d'abord le lien dans le webmail.",
"paste_hint": "Dans le webmail, ouvrez Paramètres → Sécurité → Associer l'application mobile, copiez le lien de connexion et collez-le ici.",
"paste_invalid": "Ce n'est pas un lien de connexion Bulwark",
"paste_label": "Lien de connexion",
"paste_title": "Coller le lien de connexion",
"provider_detail": "Vous le saisirez sur leur page. Bulwark ne stocke qu'un jeton de connexion, que vous pouvez révoquer à tout moment.",
"provider_title": "Votre mot de passe reste chez votre fournisseur",
"qr_hint": "Paramètres → Sécurité → Associer l'application mobile affiche un code. Pointez l'appareil photo dessus.",
@@ -515,6 +545,7 @@
"server_hint": "La même adresse que celle saisie dans le navigateur. Nous ajoutons https:// pour vous.",
"server_label": "Adresse du serveur",
"server_not_found": "Aucun serveur trouvé pour {domain}. Si vous l'hébergez vous-même, l'adresse est celle que vous utilisez pour le webmail.",
"server_paste_hint": "Vous avez plutôt un lien de connexion ? Collez-le ici.",
"server_scan_hint": "En cas de doute, scannez plutôt un code de connexion. Il contient l'adresse du serveur.",
"server_subtitle": "Saisissez l'adresse que vous utilisez pour accéder à votre webmail.",
"server_title": "Quelle est l'adresse de votre serveur ?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "מה כתובת האימייל שלך?",
"email_step_title_add": "מה הכתובת האחרת?",
"email_title": "השתמש בכתובת האימייל שלי",
"err_account_limit_detail": "הסר חשבון בהגדרות כדי להוסיף חשבון נוסף.",
"err_bad_detail": "בדוק את האימייל והסיסמה. אם החשבון שלך משתמש באימות דו-שלבי, צור סיסמת אפליקציה בדואר האינטרנט והשתמש בה כאן.",
"err_bad_title": "זה לא עבד",
"err_cert_detail": "תעודת האבטחה של השרת נדחתה. אם זה השרת שלך, ודא שהתעודה תקפה ושתוקפה לא פג.",
"err_cert_title": "לא ניתן לאמת את {host}",
"err_code_expired_detail": "קודי התחברות תקפים לכמה דקות בלבד. צור קוד חדש בדואר האינטרנט וסרוק שוב.",
"err_code_expired_detail": "קודי התחברות תקפים לשתי דקות. הצג קוד חדש בדואר האינטרנט וסרוק שוב.",
"err_code_expired_or_used_detail": "הצג קוד חדש בדואר האינטרנט וסרוק שוב.",
"err_code_expired_or_used_title": "תוקף הקוד פג או שהוא כבר נוצל",
"err_code_expired_title": "תוקף הקוד פג",
"err_code_invalid_detail": "הצג קוד חדש בדואר האינטרנט (הגדרות ← אבטחה ← הקשר יישום נייד) וסרוק אותו.",
"err_code_invalid_title": "הקוד הזה אינו תקף",
"err_code_used_detail": "כל קוד עובד פעם אחת בלבד. הצג קוד חדש בדואר האינטרנט וסרוק שוב.",
"err_code_used_title": "הקוד הזה כבר נוצל",
"err_generic_detail": "משהו השתבש. נסה שוב.",
"err_generic_title": "ההתחברות נכשלה",
"err_interrupted_detail": "התגובה לא תאמה לבקשה שהתחלנו. נסה להתחבר שוב.",
@@ -474,6 +481,17 @@
"err_no_server_title": "לא נמצא שרת דואר: {host}",
"err_no_webmail_detail": "בשרת זה אין דואר אינטרנט של Bulwark ואין התחברות OAuth. השתמש במקום זאת בסיסמה (או בסיסמת אפליקציה).",
"err_no_webmail_title": "אין דף התחברות: {host}",
"err_pair_connect_detail": "הצג קוד חדש במחשב וסרוק שוב.",
"err_pair_connect_title": "ההתחברות הצליחה, אך אין חיבור אל {host}",
"err_pair_insecure_detail": "{host} רוצה להשתמש בחיבור לא מוצפן.",
"err_pair_network_detail": "בדוק את החיבור ושהטלפון יכול להגיע לדואר האינטרנט, ונסה שוב.",
"err_pair_rate_detail": "המתן דקה ונסה שוב.",
"err_pair_server_detail": "נסה שוב עם קוד חדש. אם זה ממשיך להיכשל, פנה למנהל המערכת.",
"err_pair_server_title": "דואר האינטרנט לא הצליח להשלים את ההתחברות",
"err_pair_unsupported_detail": "{host} לא תומך בקודי התחברות. ייתכן שנדרש עדכון, או שהקישור מפנה לכתובת שגויה.",
"err_pair_unsupported_title": "דואר האינטרנט הזה לא יכול לקשר מכשירים",
"err_pair_untrusted_detail": "{host} שלח פרטי התחברות לשרת שהיישום לא יכול לאמת.",
"err_pair_untrusted_title": "אי אפשר לסמוך על ההתחברות הזו",
"err_rate_detail": "השרת ביקש שנמתין רגע. נסה שוב בקרוב.",
"err_rate_detail_seconds": "השרת ביקש שנמתין. נסה שוב בעוד כ-{seconds} שניות.",
"err_rate_title": "יותר מדי ניסיונות",
@@ -487,6 +505,12 @@
"know_server": "אני יודע את כתובת השרת שלי",
"known_server_desc": "השרת הזה כבר מוכר לנו, כך שאין צורך בהגדרה",
"known_server_title": "חשבון נוסף ב-{host}",
"link_confirm_connect": "קישור מבקש להתחבר בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.",
"link_confirm_connect_add": "קישור מבקש להוסיף חשבון באמצעות התחברות בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.",
"link_confirm_connect_title": "לפתוח את דף ההתחברות של {host}?",
"link_confirm_pair": "הקישור הזה מחבר לחשבון מ-{host}. המשך רק אם הצגת עכשיו קוד התחברות במחשב.",
"link_confirm_pair_add": "הקישור הזה מוסיף חשבון מ-{host} ומעביר אליו את היישום. המשך רק אם הצגת עכשיו קוד התחברות במחשב.",
"link_confirm_title": "להתחבר עם קישור?",
"looking_up": "מחפש את השרת שלך…",
"manual": "הזן את פרטי השרת ידנית",
"notice_bad_qr": "הקוד הזה אינו קוד התחברות של Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "מצמיד את המכשיר הזה אל {host}…",
"password_subtitle": "הסיסמה שלך נשלחת לשרת הזה בלבד ונשמרת במחזיק המפתחות של המכשיר.",
"password_title": "התחברות אל {host}",
"paste_button": "הדבק",
"paste_clipboard_empty": "אין מה להדביק. העתק קודם את הקישור בדואר האינטרנט.",
"paste_hint": "בדואר האינטרנט, פתח את הגדרות ← אבטחה ← הקשר יישום נייד, העתק את קישור ההתחברות והדבק אותו כאן.",
"paste_invalid": "זה לא קישור התחברות של Bulwark",
"paste_label": "קישור התחברות",
"paste_title": "הדבק קישור התחברות",
"provider_detail": "תקליד אותה בדף שלהם. Bulwark שומרת רק אסימון התחברות, שאפשר לבטל בכל עת.",
"provider_title": "הסיסמה שלך נשארת אצל הספק",
"qr_hint": "בהגדרות ← אבטחה ← הקשר יישום נייד מוצג קוד. כוון אליו את המצלמה.",
@@ -515,6 +545,7 @@
"server_hint": "אותה כתובת שאתה מקליד בדפדפן. נוסיף בשבילך את https://.",
"server_label": "כתובת השרת",
"server_not_found": "לא מצאנו שרת עבור {domain}. אם אתה מארח בעצמך, הכתובת היא זו שבה אתה משתמש לדואר האינטרנט.",
"server_paste_hint": "יש לך קישור התחברות במקום זאת? הדבק אותו כאן.",
"server_scan_hint": "אם אינך בטוח, סרוק במקום זאת קוד התחברות. הוא כולל את כתובת השרת.",
"server_subtitle": "הזן את הכתובת שבה אתה ניגש לדואר האינטרנט.",
"server_title": "מה כתובת השרת שלך?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Mi az e-mail-címed?",
"email_step_title_add": "Mi a másik cím?",
"email_title": "E-mail-címem használata",
"err_account_limit_detail": "Másik fiók hozzáadásához távolíts el egyet a Beállításokban.",
"err_bad_detail": "Ellenőrizd az e-mail-címet és a jelszót. Ha a fiókod kétlépcsős bejelentkezést használ, hozz létre alkalmazásjelszót a webmailben, és azt használd itt.",
"err_bad_title": "Ez nem sikerült",
"err_cert_detail": "A szerver biztonsági tanúsítványát elutasítottuk. Ha ez a saját szervered, ellenőrizd, hogy a tanúsítvány érvényes és nem járt le.",
"err_cert_title": "Nem sikerült ellenőrizni: {host}",
"err_code_expired_detail": "A bejelentkezési kódok néhány percig érvényesek. Generálj újat a webmailben, és olvasd be újra.",
"err_code_expired_detail": "A bejelentkezési kódok két percig érvényesek. Jeleníts meg újat a webmailben, és olvasd be újra.",
"err_code_expired_or_used_detail": "Jeleníts meg újat a webmailben, és olvasd be újra.",
"err_code_expired_or_used_title": "Ez a kód lejárt, vagy már felhasználták",
"err_code_expired_title": "Ez a kód lejárt",
"err_code_invalid_detail": "Jeleníts meg új kódot a webmailben (Beállítások → Biztonság → Mobilalkalmazás összekapcsolása), és olvasd be.",
"err_code_invalid_title": "Ez a kód érvénytelen",
"err_code_used_detail": "Minden kód csak egyszer használható. Jeleníts meg újat a webmailben, és olvasd be újra.",
"err_code_used_title": "Ezt a kódot már felhasználták",
"err_generic_detail": "Valami hiba történt. Próbáld újra.",
"err_generic_title": "Sikertelen bejelentkezés",
"err_interrupted_detail": "A válasz nem egyezett az elindított kéréssel. Próbálj meg újra bejelentkezni.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Nincs levelezőszerver itt: {host}",
"err_no_webmail_detail": "Ezen a szerveren nincs Bulwark webmail és OAuth-bejelentkezés sem. Használj inkább jelszót (vagy alkalmazásjelszót).",
"err_no_webmail_title": "Nincs bejelentkezési oldal itt: {host}",
"err_pair_connect_detail": "Jeleníts meg új kódot a számítógépen, és olvasd be újra.",
"err_pair_connect_title": "Bejelentkezve, de nem sikerült csatlakozni: {host}",
"err_pair_insecure_detail": "A(z) {host} titkosítatlan kapcsolatot akar használni.",
"err_pair_network_detail": "Ellenőrizd a kapcsolatot, és hogy a telefonod eléri-e a webmailt, majd próbáld újra.",
"err_pair_rate_detail": "Várj egy percet, majd próbáld újra.",
"err_pair_server_detail": "Próbáld újra egy új kóddal. Ha továbbra sem sikerül, fordulj a rendszergazdához.",
"err_pair_server_title": "A webmail nem tudta befejezni a bejelentkezést",
"err_pair_unsupported_detail": "A(z) {host} nem támogatja a bejelentkezési kódokat. Lehet, hogy frissíteni kell, vagy a hivatkozás rossz címre mutat.",
"err_pair_unsupported_title": "Ez a webmail nem tud eszközöket összekapcsolni",
"err_pair_untrusted_detail": "A(z) {host} bejelentkezési adatokat küldött egy olyan szerverhez, amelyet az alkalmazás nem tud ellenőrizni.",
"err_pair_untrusted_title": "Ebben a bejelentkezésben nem lehet megbízni",
"err_rate_detail": "A szerver arra kért, hogy várjunk egy kicsit. Próbáld újra hamarosan.",
"err_rate_detail_seconds": "A szerver arra kért, hogy várjunk. Próbáld újra kb. {seconds} másodperc múlva.",
"err_rate_title": "Túl sok próbálkozás",
@@ -487,6 +505,12 @@
"know_server": "Ismerem a szerverem címét",
"known_server_desc": "Ezt a szervert már ismerjük, így nincs mit beállítani",
"known_server_title": "Másik fiók itt: {host}",
"link_confirm_connect": "Egy hivatkozás bejelentkezést kér ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.",
"link_confirm_connect_add": "Egy hivatkozás egy fiók hozzáadását kéri bejelentkezéssel ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.",
"link_confirm_connect_title": "Megnyitod a(z) {host} bejelentkezési oldalát?",
"link_confirm_pair": "Ez a hivatkozás bejelentkeztet egy fiókba itt: {host}. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.",
"link_confirm_pair_add": "Ez a hivatkozás hozzáad egy fiókot innen: {host}, és átváltja rá az alkalmazást. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.",
"link_confirm_title": "Bejelentkezel egy hivatkozással?",
"looking_up": "Szerver keresése…",
"manual": "Szerveradatok kézi megadása",
"notice_bad_qr": "Ez nem Bulwark bejelentkezési kód",
@@ -505,6 +529,12 @@
"pairing_detail": "Az eszköz párosítása: {host}…",
"password_subtitle": "A jelszavadat csak erre a szerverre küldjük el, és az eszköz kulcstárolójában tároljuk.",
"password_title": "Bejelentkezés: {host}",
"paste_button": "Beillesztés",
"paste_clipboard_empty": "Nincs mit beilleszteni. Előbb másold ki a hivatkozást a webmailben.",
"paste_hint": "A webmailben nyisd meg a Beállítások → Biztonság → Mobilalkalmazás összekapcsolása menüt, másold ki a bejelentkezési hivatkozást, és illeszd be ide.",
"paste_invalid": "Ez nem Bulwark bejelentkezési hivatkozás",
"paste_label": "Bejelentkezési hivatkozás",
"paste_title": "Bejelentkezési hivatkozás beillesztése",
"provider_detail": "A szolgáltató oldalán írod be. A Bulwark csak egy bejelentkezési tokent tárol, amelyet bármikor visszavonhatsz.",
"provider_title": "A jelszavad a szolgáltatódnál marad",
"qr_hint": "A Beállítások → Biztonság → Mobilalkalmazás összekapcsolása egy kódot jelenít meg. Irányítsd rá a kamerát.",
@@ -515,6 +545,7 @@
"server_hint": "Ugyanaz a cím, amelyet a böngészőbe írsz. A https:// előtagot mi tesszük hozzá.",
"server_label": "Szervercím",
"server_not_found": "Nem találtunk szervert ehhez: {domain}. Ha saját szervert üzemeltetsz, a webmailhez használt címet add meg.",
"server_paste_hint": "Inkább bejelentkezési hivatkozásod van? Illeszd be ide.",
"server_scan_hint": "Ha nem vagy biztos benne, olvass be inkább egy bejelentkezési kódot. Ez tartalmazza a szerver címét.",
"server_subtitle": "Add meg a címet, amelyen a webmailt eléred.",
"server_title": "Mi a szervered címe?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Qual è il tuo indirizzo email?",
"email_step_title_add": "Qual è l'altro indirizzo?",
"email_title": "Usa il mio indirizzo email",
"err_account_limit_detail": "Rimuovi un account in Impostazioni per aggiungerne un altro.",
"err_bad_detail": "Controlla email e password. Se il tuo account usa l'autenticazione a due fattori, crea una password per le app nella webmail e usala qui.",
"err_bad_title": "Non ha funzionato",
"err_cert_detail": "Il certificato di sicurezza del server è stato rifiutato. Se è il tuo server, verifica che il certificato sia valido e non scaduto.",
"err_cert_title": "Impossibile verificare {host}",
"err_code_expired_detail": "I codici di accesso sono validi per pochi minuti. Generane uno nuovo nella webmail e scansionalo di nuovo.",
"err_code_expired_detail": "I codici di accesso sono validi per due minuti. Mostrane uno nuovo nella webmail e scansionalo di nuovo.",
"err_code_expired_or_used_detail": "Mostrane uno nuovo nella webmail e scansionalo di nuovo.",
"err_code_expired_or_used_title": "Questo codice è scaduto o è già stato usato",
"err_code_expired_title": "Questo codice è scaduto",
"err_code_invalid_detail": "Mostra un nuovo codice nella webmail (Impostazioni → Sicurezza → Collega l'app mobile) e scansionalo.",
"err_code_invalid_title": "Questo codice non è valido",
"err_code_used_detail": "Ogni codice funziona una sola volta. Mostrane uno nuovo nella webmail e scansionalo di nuovo.",
"err_code_used_title": "Questo codice è già stato usato",
"err_generic_detail": "Si è verificato un errore. Riprova.",
"err_generic_title": "Accesso non riuscito",
"err_interrupted_detail": "La risposta non corrisponde alla richiesta che abbiamo avviato. Prova ad accedere di nuovo.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Nessun server di posta su {host}",
"err_no_webmail_detail": "Questo server non ha una webmail Bulwark né l'accesso OAuth. Usa una password (o una password per le app).",
"err_no_webmail_title": "Nessuna pagina di accesso su {host}",
"err_pair_connect_detail": "Mostra un nuovo codice sul computer e scansionalo di nuovo.",
"err_pair_connect_title": "Accesso eseguito, ma impossibile connettersi a {host}",
"err_pair_insecure_detail": "{host} vuole usare una connessione non cifrata.",
"err_pair_network_detail": "Controlla la connessione e che il telefono possa raggiungere la webmail, poi riprova.",
"err_pair_rate_detail": "Attendi un minuto, poi riprova.",
"err_pair_server_detail": "Riprova con un nuovo codice. Se continua a non funzionare, rivolgiti all'amministratore.",
"err_pair_server_title": "La webmail non è riuscita a completare l'accesso",
"err_pair_unsupported_detail": "{host} non supporta i codici di accesso. Potrebbe servire un aggiornamento, oppure il link punta all'indirizzo sbagliato.",
"err_pair_unsupported_title": "Questa webmail non può collegare dispositivi",
"err_pair_untrusted_detail": "{host} ha inviato dati di accesso per un server che l'app non può verificare.",
"err_pair_untrusted_title": "Questo accesso non è affidabile",
"err_rate_detail": "Il server ci ha chiesto di attendere un momento. Riprova tra poco.",
"err_rate_detail_seconds": "Il server ci ha chiesto di attendere. Riprova tra circa {seconds} secondi.",
"err_rate_title": "Troppi tentativi",
@@ -487,6 +505,12 @@
"know_server": "Conosco l'indirizzo del mio server",
"known_server_desc": "Conosciamo già questo server, quindi non c'è nulla da configurare",
"known_server_title": "Un altro account su {host}",
"link_confirm_connect": "Un link chiede di accedere su questa pagina. Continua solo se usi {host} per la tua posta.",
"link_confirm_connect_add": "Un link chiede di aggiungere un account accedendo su questa pagina. Continua solo se usi {host} per la tua posta.",
"link_confirm_connect_title": "Aprire la pagina di accesso di {host}?",
"link_confirm_pair": "Questo link ti fa accedere a un account di {host}. Continua solo se hai appena mostrato un codice di accesso sul computer.",
"link_confirm_pair_add": "Questo link aggiunge un account di {host} e passa l'app a quell'account. Continua solo se hai appena mostrato un codice di accesso sul computer.",
"link_confirm_title": "Accedere con un link?",
"looking_up": "Ricerca del tuo server…",
"manual": "Inserisci i dati del server manualmente",
"notice_bad_qr": "Questo codice non è un codice di accesso Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Associazione di questo dispositivo con {host}…",
"password_subtitle": "La password viene inviata solo a questo server e salvata nell'archivio sicuro del dispositivo.",
"password_title": "Accedi a {host}",
"paste_button": "Incolla",
"paste_clipboard_empty": "Niente da incollare. Copia prima il link nella webmail.",
"paste_hint": "Nella webmail apri Impostazioni → Sicurezza → Collega l'app mobile, copia il link di accesso e incollalo qui.",
"paste_invalid": "Questo non è un link di accesso Bulwark",
"paste_label": "Link di accesso",
"paste_title": "Incolla link di accesso",
"provider_detail": "La digiterai nella loro pagina. Bulwark salva solo un token di accesso, che puoi revocare in qualsiasi momento.",
"provider_title": "La tua password resta al tuo provider",
"qr_hint": "Impostazioni → Sicurezza → Collega l'app mobile mostra un codice. Inquadralo con la fotocamera.",
@@ -515,6 +545,7 @@
"server_hint": "Lo stesso indirizzo che digiti nel browser. Aggiungeremo noi https://.",
"server_label": "Indirizzo del server",
"server_not_found": "Non abbiamo trovato un server per {domain}. Se lo gestisci tu, l'indirizzo è quello che usi per la webmail.",
"server_paste_hint": "Hai invece un link di accesso? Incollalo qui.",
"server_scan_hint": "Se non sei sicuro, scansiona un codice di accesso: contiene l'indirizzo del server.",
"server_subtitle": "Inserisci l'indirizzo che usi per raggiungere la tua webmail.",
"server_title": "Qual è l'indirizzo del tuo server?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "メールアドレスを入力してください",
"email_step_title_add": "もう一つのメールアドレスを入力してください",
"email_title": "メールアドレスを使用",
"err_account_limit_detail": "別のアカウントを追加するには、設定でアカウントを削除してください。",
"err_bad_detail": "メールアドレスとパスワードを確認してください。アカウントで二要素認証を使用している場合は、ウェブメールでアプリパスワードを作成し、ここで使用してください。",
"err_bad_title": "サインインできませんでした",
"err_cert_detail": "サーバーのセキュリティ証明書が拒否されました。ご自身のサーバーの場合は、証明書が有効で期限切れでないことを確認してください。",
"err_cert_title": "{host}を検証できませんでした",
"err_code_expired_detail": "サインインコードの有効期間は数分です。ウェブメールで新しいコードを生成して、もう一度スキャンしてください。",
"err_code_expired_detail": "サインインコードの有効期間は2分です。ウェブメールで新しいコードを表示して、もう一度スキャンしてください。",
"err_code_expired_or_used_detail": "ウェブメールで新しいコードを表示して、もう一度スキャンしてください。",
"err_code_expired_or_used_title": "このコードは期限切れか、すでに使用されています",
"err_code_expired_title": "コードの有効期限が切れています",
"err_code_invalid_detail": "ウェブメールで新しいコードを表示し(「設定 → セキュリティ → モバイルアプリを連携」)、スキャンしてください。",
"err_code_invalid_title": "このコードは無効です",
"err_code_used_detail": "各コードは1回しか使えません。ウェブメールで新しいコードを表示して、もう一度スキャンしてください。",
"err_code_used_title": "このコードはすでに使用されています",
"err_generic_detail": "問題が発生しました。もう一度お試しください。",
"err_generic_title": "サインインに失敗しました",
"err_interrupted_detail": "応答が開始したリクエストと一致しませんでした。もう一度サインインしてください。",
@@ -474,6 +481,17 @@
"err_no_server_title": "{host}にメールサーバーがありません",
"err_no_webmail_detail": "このサーバーには Bulwark ウェブメールも OAuth サインインもありません。代わりにパスワード(またはアプリパスワード)を使用してください。",
"err_no_webmail_title": "{host}にサインインページがありません",
"err_pair_connect_detail": "コンピューターで新しいコードを表示して、もう一度スキャンしてください。",
"err_pair_connect_title": "サインインしましたが、{host} に接続できませんでした",
"err_pair_insecure_detail": "{host} が暗号化されていない接続を使おうとしています。",
"err_pair_network_detail": "接続状況と、スマートフォンからウェブメールにアクセスできるかを確認して、もう一度お試しください。",
"err_pair_rate_detail": "1分待ってから、もう一度お試しください。",
"err_pair_server_detail": "新しいコードでもう一度お試しください。それでも失敗する場合は、管理者にお問い合わせください。",
"err_pair_server_title": "ウェブメールでサインインを完了できませんでした",
"err_pair_unsupported_detail": "{host} はサインインコードに対応していません。アップデートが必要か、リンクのアドレスが間違っている可能性があります。",
"err_pair_unsupported_title": "このウェブメールはデバイスを連携できません",
"err_pair_untrusted_detail": "{host} から、アプリが検証できないサーバー用のサインイン情報が送られてきました。",
"err_pair_untrusted_title": "このサインインは信頼できません",
"err_rate_detail": "サーバーから少し待つよう求められました。しばらくしてからもう一度お試しください。",
"err_rate_detail_seconds": "サーバーから待機するよう求められました。約{seconds}秒後にもう一度お試しください。",
"err_rate_title": "試行回数が多すぎます",
@@ -487,6 +505,12 @@
"know_server": "サーバーアドレスがわかる",
"known_server_desc": "このサーバーは登録済みのため、設定は不要です",
"known_server_title": "{host}の別のアカウント",
"link_confirm_connect": "リンクがこのページでのサインインを求めています。メールに {host} を使っている場合にのみ続行してください。",
"link_confirm_connect_add": "リンクがこのページでサインインしてアカウントを追加するよう求めています。メールに {host} を使っている場合にのみ続行してください。",
"link_confirm_connect_title": "{host} のサインインページを開きますか?",
"link_confirm_pair": "このリンクは {host} のアカウントにサインインします。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。",
"link_confirm_pair_add": "このリンクは {host} のアカウントを追加し、アプリをそのアカウントに切り替えます。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。",
"link_confirm_title": "リンクでサインインしますか?",
"looking_up": "サーバーを検索しています…",
"manual": "サーバー情報を手動で入力",
"notice_bad_qr": "このコードは Bulwark のサインインコードではありません",
@@ -505,6 +529,12 @@
"pairing_detail": "このデバイスを{host}とペアリングしています…",
"password_subtitle": "パスワードはこのサーバーにのみ送信され、デバイスのキーチェーンに保存されます。",
"password_title": "{host}にサインイン",
"paste_button": "貼り付け",
"paste_clipboard_empty": "貼り付ける内容がありません。先にウェブメールでリンクをコピーしてください。",
"paste_hint": "ウェブメールで「設定 → セキュリティ → モバイルアプリを連携」を開き、サインインリンクをコピーしてここに貼り付けてください。",
"paste_invalid": "これは Bulwark のサインインリンクではありません",
"paste_label": "サインインリンク",
"paste_title": "サインインリンクを貼り付け",
"provider_detail": "パスワードはプロバイダーのページで入力します。Bulwark が保存するのはサインイントークンのみで、いつでも取り消すことができます。",
"provider_title": "パスワードはプロバイダーにのみ送信されます",
"qr_hint": "「設定 → セキュリティ → モバイルアプリを連携」でコードが表示されます。カメラをコードに向けてください。",
@@ -515,6 +545,7 @@
"server_hint": "ブラウザーで入力するものと同じアドレスです。https:// は自動で追加されます。",
"server_label": "サーバーアドレス",
"server_not_found": "{domain}のサーバーが見つかりませんでした。セルフホストしている場合は、ウェブメールで使用しているアドレスを入力してください。",
"server_paste_hint": "代わりにサインインリンクをお持ちですか?ここに貼り付けてください。",
"server_scan_hint": "わからない場合は、代わりにサインインコードをスキャンしてください。コードにサーバーアドレスが含まれています。",
"server_subtitle": "ウェブメールへのアクセスに使用しているアドレスを入力してください。",
"server_title": "サーバーアドレスを入力してください",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "이메일 주소가 무엇인가요?",
"email_step_title_add": "다른 주소는 무엇인가요?",
"email_title": "내 이메일 주소 사용",
"err_account_limit_detail": "다른 계정을 추가하려면 설정에서 계정을 삭제하세요.",
"err_bad_detail": "이메일과 비밀번호를 확인해 주세요. 계정에 2단계 인증을 사용 중이라면 웹메일에서 앱 비밀번호를 만들어 여기에 입력해 주세요.",
"err_bad_title": "로그인하지 못했어요",
"err_cert_detail": "서버의 보안 인증서가 거부되었어요. 직접 운영하는 서버라면 인증서가 유효하고 만료되지 않았는지 확인해 주세요.",
"err_cert_title": "{host}을(를) 확인할 수 없어요",
"err_code_expired_detail": "로그인 코드는 몇 분 동안만 유효해요. 웹메일에서 새 코드를 생성한 후 다시 스캔해 주세요.",
"err_code_expired_detail": "로그인 코드는 2분 동안 유효해요. 웹메일에서 새 코드를 표시한 후 다시 스캔해 주세요.",
"err_code_expired_or_used_detail": "웹메일에서 새 코드를 표시한 후 다시 스캔해 주세요.",
"err_code_expired_or_used_title": "만료되었거나 이미 사용된 코드예요",
"err_code_expired_title": "코드가 만료되었어요",
"err_code_invalid_detail": "웹메일에서 새 코드를 표시한 후(설정 → 보안 → 모바일 앱 연결) 스캔해 주세요.",
"err_code_invalid_title": "유효하지 않은 코드예요",
"err_code_used_detail": "각 코드는 한 번만 사용할 수 있어요. 웹메일에서 새 코드를 표시한 후 다시 스캔해 주세요.",
"err_code_used_title": "이미 사용된 코드예요",
"err_generic_detail": "문제가 발생했어요. 다시 시도해 주세요.",
"err_generic_title": "로그인 실패",
"err_interrupted_detail": "응답이 시작한 요청과 일치하지 않아요. 다시 로그인해 주세요.",
@@ -474,6 +481,17 @@
"err_no_server_title": "{host}에 메일 서버가 없어요",
"err_no_webmail_detail": "이 서버에는 Bulwark 웹메일과 OAuth 로그인이 없어요. 대신 비밀번호(또는 앱 비밀번호)를 사용해 주세요.",
"err_no_webmail_title": "{host}에 로그인 페이지가 없어요",
"err_pair_connect_detail": "컴퓨터에서 새 코드를 표시한 후 다시 스캔해 주세요.",
"err_pair_connect_title": "로그인했지만 {host}에 연결하지 못했어요",
"err_pair_insecure_detail": "{host}에서 암호화되지 않은 연결을 사용하려고 해요.",
"err_pair_network_detail": "연결 상태와 휴대폰에서 웹메일에 접속할 수 있는지 확인한 후 다시 시도해 주세요.",
"err_pair_rate_detail": "1분 정도 기다린 후 다시 시도해 주세요.",
"err_pair_server_detail": "새 코드로 다시 시도해 주세요. 계속 실패하면 관리자에게 문의해 주세요.",
"err_pair_server_title": "웹메일에서 로그인을 완료하지 못했어요",
"err_pair_unsupported_detail": "{host}에서 로그인 코드를 지원하지 않아요. 업데이트가 필요하거나 링크가 잘못된 주소를 가리키고 있을 수 있어요.",
"err_pair_unsupported_title": "이 웹메일은 기기를 연결할 수 없어요",
"err_pair_untrusted_detail": "{host}에서 앱이 확인할 수 없는 서버의 로그인 정보를 보냈어요.",
"err_pair_untrusted_title": "이 로그인은 신뢰할 수 없어요",
"err_rate_detail": "서버에서 잠시 기다려 달라고 요청했어요. 잠시 후 다시 시도해 주세요.",
"err_rate_detail_seconds": "서버에서 기다려 달라고 요청했어요. 약 {seconds}초 후에 다시 시도해 주세요.",
"err_rate_title": "시도 횟수가 너무 많아요",
@@ -487,6 +505,12 @@
"know_server": "서버 주소를 알고 있어요",
"known_server_desc": "이미 알고 있는 서버라서 설정이 필요 없어요",
"known_server_title": "{host}의 다른 계정",
"link_confirm_connect": "링크가 이 페이지에서 로그인하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.",
"link_confirm_connect_add": "링크가 이 페이지에서 로그인해 계정을 추가하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.",
"link_confirm_connect_title": "{host}의 로그인 페이지를 열까요?",
"link_confirm_pair": "이 링크는 {host}의 계정으로 로그인해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.",
"link_confirm_pair_add": "이 링크는 {host}의 계정을 추가하고 앱을 해당 계정으로 전환해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.",
"link_confirm_title": "링크로 로그인할까요?",
"looking_up": "서버를 찾는 중…",
"manual": "서버 정보 직접 입력",
"notice_bad_qr": "Bulwark 로그인 코드가 아니에요",
@@ -505,6 +529,12 @@
"pairing_detail": "이 기기를 {host}와(과) 페어링하는 중…",
"password_subtitle": "비밀번호는 이 서버로만 전송되며 기기의 키체인에 저장돼요.",
"password_title": "{host}에 로그인",
"paste_button": "붙여넣기",
"paste_clipboard_empty": "붙여넣을 내용이 없어요. 먼저 웹메일에서 링크를 복사해 주세요.",
"paste_hint": "웹메일에서 설정 → 보안 → 모바일 앱 연결을 열고 로그인 링크를 복사해 여기에 붙여넣어 주세요.",
"paste_invalid": "Bulwark 로그인 링크가 아니에요",
"paste_label": "로그인 링크",
"paste_title": "로그인 링크 붙여넣기",
"provider_detail": "비밀번호는 제공업체의 페이지에서 입력해요. Bulwark는 로그인 토큰만 저장하며, 언제든지 해지할 수 있어요.",
"provider_title": "비밀번호는 제공업체에만 전달돼요",
"qr_hint": "설정 → 보안 → 모바일 앱 연결에서 코드가 표시돼요. 카메라를 코드에 비춰 주세요.",
@@ -515,6 +545,7 @@
"server_hint": "브라우저에 입력하는 주소와 같아요. https://는 자동으로 추가돼요.",
"server_label": "서버 주소",
"server_not_found": "{domain}의 서버를 찾을 수 없어요. 직접 호스팅하는 경우 웹메일에 사용하는 주소를 입력해 주세요.",
"server_paste_hint": "로그인 링크가 있나요? 여기에 붙여넣어 주세요.",
"server_scan_hint": "잘 모르겠다면 대신 로그인 코드를 스캔해 주세요. 코드에 서버 주소가 포함되어 있어요.",
"server_subtitle": "웹메일에 접속할 때 사용하는 주소를 입력해 주세요.",
"server_title": "서버 주소가 무엇인가요?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Kāda ir jūsu e-pasta adrese?",
"email_step_title_add": "Kāda ir otra adrese?",
"email_title": "Izmantot manu e-pasta adresi",
"err_account_limit_detail": "Lai pievienotu citu kontu, noņemiet kādu kontu iestatījumos.",
"err_bad_detail": "Pārbaudiet e-pasta adresi un paroli. Ja jūsu kontā tiek izmantota divpakāpju pierakstīšanās, izveidojiet lietotnes paroli tīmekļa pastā un izmantojiet to šeit.",
"err_bad_title": "Tas neizdevās",
"err_cert_detail": "Servera drošības sertifikāts tika noraidīts. Ja tas ir jūsu serveris, pārbaudiet, vai sertifikāts ir derīgs un tā termiņš nav beidzies.",
"err_cert_title": "Neizdevās pārbaudīt: {host}",
"err_code_expired_detail": "Pierakstīšanās kodi ir derīgi dažas minūtes. Izveidojiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
"err_code_expired_detail": "Pierakstīšanās kodi ir derīgi divas minūtes. Parādiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
"err_code_expired_or_used_detail": "Parādiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
"err_code_expired_or_used_title": "Šī koda derīgums ir beidzies, vai tas jau ir izmantots",
"err_code_expired_title": "Šī koda derīgums ir beidzies",
"err_code_invalid_detail": "Parādiet jaunu kodu tīmekļa pastā (Iestatījumi → Drošība → Saistīt mobilo lietotni) un noskenējiet to.",
"err_code_invalid_title": "Šis kods nav derīgs",
"err_code_used_detail": "Katrs kods darbojas tikai vienu reizi. Parādiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
"err_code_used_title": "Šis kods jau ir izmantots",
"err_generic_detail": "Kaut kas nogāja greizi. Mēģiniet vēlreiz.",
"err_generic_title": "Pierakstīšanās neizdevās",
"err_interrupted_detail": "Atbilde neatbilda sāktajam pieprasījumam. Mēģiniet pierakstīties vēlreiz.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Pasta serveris nav atrasts: {host}",
"err_no_webmail_detail": "Šim serverim nav Bulwark tīmekļa pasta un OAuth pierakstīšanās. Tā vietā izmantojiet paroli (vai lietotnes paroli).",
"err_no_webmail_title": "Pierakstīšanās lapa nav atrasta: {host}",
"err_pair_connect_detail": "Parādiet jaunu kodu datorā un skenējiet vēlreiz.",
"err_pair_connect_title": "Pierakstījāties, bet neizdevās izveidot savienojumu ar {host}",
"err_pair_insecure_detail": "{host} vēlas izmantot nešifrētu savienojumu.",
"err_pair_network_detail": "Pārbaudiet savienojumu un to, vai tālrunis var piekļūt tīmekļa pastam, un mēģiniet vēlreiz.",
"err_pair_rate_detail": "Uzgaidiet minūti un mēģiniet vēlreiz.",
"err_pair_server_detail": "Mēģiniet vēlreiz ar jaunu kodu. Ja tas joprojām neizdodas, sazinieties ar administratoru.",
"err_pair_server_title": "Tīmekļa pasts nevarēja pabeigt pierakstīšanos",
"err_pair_unsupported_detail": "{host} neatbalsta pierakstīšanās kodus. Iespējams, tam nepieciešams atjauninājums, vai saite norāda uz nepareizu adresi.",
"err_pair_unsupported_title": "Šis tīmekļa pasts nevar saistīt ierīces",
"err_pair_untrusted_detail": "{host} nosūtīja pierakstīšanās datus serverim, ko lietotne nevar pārbaudīt.",
"err_pair_untrusted_title": "Šai pierakstīšanās reizei nevar uzticēties",
"err_rate_detail": "Serveris lūdza brīdi uzgaidīt. Drīz mēģiniet vēlreiz.",
"err_rate_detail_seconds": "Serveris lūdza uzgaidīt. Mēģiniet vēlreiz pēc aptuveni {seconds} s.",
"err_rate_title": "Pārāk daudz mēģinājumu",
@@ -487,6 +505,12 @@
"know_server": "Es zinu sava servera adresi",
"known_server_desc": "Šis serveris mums jau ir zināms, tāpēc nekas nav jāiestata",
"known_server_title": "Vēl viens konts: {host}",
"link_confirm_connect": "Saite aicina pierakstīties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.",
"link_confirm_connect_add": "Saite aicina pievienot kontu, pierakstoties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.",
"link_confirm_connect_title": "Atvērt {host} pierakstīšanās lapu?",
"link_confirm_pair": "Šī saite pierakstās kontā no {host}. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.",
"link_confirm_pair_add": "Šī saite pievieno kontu no {host} un pārslēdz lietotni uz to. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.",
"link_confirm_title": "Pierakstīties ar saiti?",
"looking_up": "Meklē jūsu serveri…",
"manual": "Ievadīt servera datus manuāli",
"notice_bad_qr": "Šis nav Bulwark pierakstīšanās kods",
@@ -505,6 +529,12 @@
"pairing_detail": "Šī ierīce tiek savienota pārī: {host}…",
"password_subtitle": "Jūsu parole tiek nosūtīta tikai uz šo serveri un glabāta ierīces atslēgu krātuvē.",
"password_title": "Pierakstīšanās: {host}",
"paste_button": "Ielīmēt",
"paste_clipboard_empty": "Nav ko ielīmēt. Vispirms nokopējiet saiti tīmekļa pastā.",
"paste_hint": "Tīmekļa pastā atveriet Iestatījumi → Drošība → Saistīt mobilo lietotni, nokopējiet pierakstīšanās saiti un ielīmējiet to šeit.",
"paste_invalid": "Šī nav Bulwark pierakstīšanās saite",
"paste_label": "Pierakstīšanās saite",
"paste_title": "Ielīmēt pierakstīšanās saiti",
"provider_detail": "Jūs to ievadīsiet pakalpojumu sniedzēja lapā. Bulwark glabā tikai pierakstīšanās tokenu, ko varat jebkurā laikā atsaukt.",
"provider_title": "Jūsu parole paliek pie pakalpojumu sniedzēja",
"qr_hint": "Sadaļā Iestatījumi → Drošība → Saistīt mobilo lietotni tiek parādīts kods. Pavērsiet pret to kameru.",
@@ -515,6 +545,7 @@
"server_hint": "Tā pati adrese, ko rakstāt pārlūkā. https:// pievienosim mēs.",
"server_label": "Servera adrese",
"server_not_found": "Mēs neatradām serveri domēnam {domain}. Ja izmantojat savu serveri, norādiet adresi, ko lietojat tīmekļa pastam.",
"server_paste_hint": "Jums ir pierakstīšanās saite? Ielīmējiet to šeit.",
"server_scan_hint": "Ja šaubāties, tā vietā skenējiet pierakstīšanās kodu. Tajā ir servera adrese.",
"server_subtitle": "Ievadiet adresi, ar kuru atverat savu tīmekļa pastu.",
"server_title": "Kāda ir jūsu servera adrese?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Таны имэйл хаяг юу вэ?",
"email_step_title_add": "Нөгөө хаяг юу вэ?",
"email_title": "Имэйл хаягаа ашиглах",
"err_account_limit_detail": "Өөр бүртгэл нэмэхийн тулд Тохиргооноос нэг бүртгэлийг хасна уу.",
"err_bad_detail": "Имэйл болон нууц үгээ шалгана уу. Хэрэв таны бүртгэл хоёр шаттай нэвтрэлт ашигладаг бол вэбмэйлд аппын нууц үг үүсгээд энд ашиглана уу.",
"err_bad_title": "Амжилтгүй боллоо",
"err_cert_detail": "Серверийн аюулгүй байдлын гэрчилгээг татгалзсан. Хэрэв энэ таны өөрийн сервер бол гэрчилгээ хүчинтэй, хугацаа нь дуусаагүй эсэхийг шалгана уу.",
"err_cert_title": "Баталгаажуулж чадсангүй: {host}",
"err_code_expired_detail": "Нэвтрэх код хэдхэн минут хүчинтэй. Вэбмэйлд шинэ код үүсгээд дахин скан хийнэ үү.",
"err_code_expired_detail": "Нэвтрэх код хоёр минут хүчинтэй. Вэбмэйлд шинэ код харуулаад дахин скан хийнэ үү.",
"err_code_expired_or_used_detail": "Вэбмэйлд шинэ код харуулаад дахин скан хийнэ үү.",
"err_code_expired_or_used_title": "Энэ кодын хугацаа дууссан эсвэл аль хэдийн ашиглагдсан",
"err_code_expired_title": "Кодын хугацаа дууссан",
"err_code_invalid_detail": "Вэбмэйлд шинэ код харуулаад (Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох) скан хийнэ үү.",
"err_code_invalid_title": "Энэ код хүчингүй байна",
"err_code_used_detail": "Код бүр зөвхөн нэг удаа ажиллана. Вэбмэйлд шинэ код харуулаад дахин скан хийнэ үү.",
"err_code_used_title": "Энэ код аль хэдийн ашиглагдсан",
"err_generic_detail": "Ямар нэг алдаа гарлаа. Дахин оролдоно уу.",
"err_generic_title": "Нэвтэрч чадсангүй",
"err_interrupted_detail": "Хариу нь бидний эхлүүлсэн хүсэлттэй таарахгүй байна. Дахин нэвтэрч үзнэ үү.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Мэйл сервер алга: {host}",
"err_no_webmail_detail": "Энэ серверт Bulwark вэбмэйл болон OAuth нэвтрэлт байхгүй. Оронд нь нууц үг (эсвэл аппын нууц үг) ашиглана уу.",
"err_no_webmail_title": "Нэвтрэх хуудас алга: {host}",
"err_pair_connect_detail": "Компьютер дээрээ шинэ код харуулаад дахин скан хийнэ үү.",
"err_pair_connect_title": "Нэвтэрсэн ч {host} руу холбогдож чадсангүй",
"err_pair_insecure_detail": "{host} шифрлэгдээгүй холболт ашиглахыг хүсэж байна.",
"err_pair_network_detail": "Холболтоо болон утас тань вэбмэйлд хүрч чадаж байгаа эсэхийг шалгаад дахин оролдоно уу.",
"err_pair_rate_detail": "Нэг минут хүлээгээд дахин оролдоно уу.",
"err_pair_server_detail": "Шинэ кодоор дахин оролдоно уу. Алдаа үргэлжилбэл админтайгаа холбогдоно уу.",
"err_pair_server_title": "Вэбмэйл нэвтрэлтийг дуусгаж чадсангүй",
"err_pair_unsupported_detail": "{host} нэвтрэх кодыг дэмждэггүй. Шинэчлэл шаардлагатай байж магадгүй, эсвэл холбоос буруу хаяг руу заасан байна.",
"err_pair_unsupported_title": "Энэ вэбмэйл төхөөрөмж холбох боломжгүй",
"err_pair_untrusted_detail": "{host} програмын баталгаажуулж чадахгүй серверийн нэвтрэх мэдээллийг илгээсэн.",
"err_pair_untrusted_title": "Энэ нэвтрэлтэд итгэх боломжгүй",
"err_rate_detail": "Сервер түр хүлээхийг хүслээ. Удахгүй дахин оролдоно уу.",
"err_rate_detail_seconds": "Сервер хүлээхийг хүслээ. Ойролцоогоор {seconds} секундын дараа дахин оролдоно уу.",
"err_rate_title": "Хэт олон оролдлого",
@@ -487,6 +505,12 @@
"know_server": "Би серверийн хаягаа мэднэ",
"known_server_desc": "Бид энэ серверийг аль хэдийн мэддэг тул тохируулах шаардлагагүй",
"known_server_title": "{host} дээрх өөр бүртгэл",
"link_confirm_connect": "Холбоос энэ хуудсанд нэвтрэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.",
"link_confirm_connect_add": "Холбоос энэ хуудсанд нэвтэрч бүртгэл нэмэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.",
"link_confirm_connect_title": "{host}-ийн нэвтрэх хуудсыг нээх үү?",
"link_confirm_pair": "Энэ холбоос {host} дээрх бүртгэлд нэвтрүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.",
"link_confirm_pair_add": "Энэ холбоос {host} дээрх бүртгэлийг нэмээд програмыг түүн рүү шилжүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.",
"link_confirm_title": "Холбоосоор нэвтрэх үү?",
"looking_up": "Таны серверийг хайж байна…",
"manual": "Серверийн мэдээллийг гараар оруулах",
"notice_bad_qr": "Энэ код Bulwark-ийн нэвтрэх код биш байна",
@@ -505,6 +529,12 @@
"pairing_detail": "Энэ төхөөрөмжийг холбож байна: {host}…",
"password_subtitle": "Таны нууц үгийг зөвхөн энэ сервер рүү илгээх бөгөөд төхөөрөмжийн түлхүүрийн санд хадгална.",
"password_title": "Нэвтрэх: {host}",
"paste_button": "Буулгах",
"paste_clipboard_empty": "Буулгах зүйл алга. Эхлээд вэбмэйлээс холбоосыг хуулна уу.",
"paste_hint": "Вэбмэйлд Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох хэсгийг нээж, нэвтрэх холбоосыг хуулаад энд буулгана уу.",
"paste_invalid": "Энэ Bulwark-ийн нэвтрэх холбоос биш байна",
"paste_label": "Нэвтрэх холбоос",
"paste_title": "Нэвтрэх холбоос буулгах",
"provider_detail": "Та нууц үгээ тэдний хуудсанд бичнэ. Bulwark зөвхөн нэвтрэх токен хадгалах бөгөөд та үүнийг хүссэн үедээ хүчингүй болгож болно.",
"provider_title": "Таны нууц үг үйлчилгээ үзүүлэгч дээрээ үлдэнэ",
"qr_hint": "Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох хэсэгт код гарна. Камераа түүн рүү чиглүүлнэ үү.",
@@ -515,6 +545,7 @@
"server_hint": "Хөтөч дээр бичдэг хаяг. https://-г бид нэмнэ.",
"server_label": "Серверийн хаяг",
"server_not_found": "{domain} домэйны сервер олдсонгүй. Хэрэв та өөрөө сервертэй бол вэбмэйлд ашигладаг хаягаа оруулна уу.",
"server_paste_hint": "Оронд нь нэвтрэх холбоос байгаа юу? Энд буулгана уу.",
"server_scan_hint": "Итгэлгүй байвал оронд нь нэвтрэх код скан хийнэ үү. Үүнд серверийн хаяг агуулагддаг.",
"server_subtitle": "Вэбмэйл рүүгээ ороход ашигладаг хаягаа оруулна уу.",
"server_title": "Таны серверийн хаяг юу вэ?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Hva er e-postadressen din?",
"email_step_title_add": "Hva er den andre adressen?",
"email_title": "Bruk e-postadressen min",
"err_account_limit_detail": "Fjern en konto i Innstillinger for å legge til en ny.",
"err_bad_detail": "Sjekk e-postadressen og passordet. Hvis kontoen din bruker tofaktorautentisering, oppretter du et app-passord i nettposten og bruker det her.",
"err_bad_title": "Det fungerte ikke",
"err_cert_detail": "Serverens sikkerhetssertifikat ble avvist. Hvis dette er din egen server, må du sjekke at sertifikatet er gyldig og ikke utløpt.",
"err_cert_title": "Kunne ikke bekrefte {host}",
"err_code_expired_detail": "Påloggingskoder er bare gyldige i noen få minutter. Lag en ny i nettposten, og skann på nytt.",
"err_code_expired_detail": "Påloggingskoder er gyldige i to minutter. Vis en ny i nettposten, og skann på nytt.",
"err_code_expired_or_used_detail": "Vis en ny i nettposten, og skann på nytt.",
"err_code_expired_or_used_title": "Koden er utløpt eller allerede brukt",
"err_code_expired_title": "Koden er utløpt",
"err_code_invalid_detail": "Vis en ny kode i nettposten (Innstillinger → Sikkerhet → Koble til mobilappen), og skann den.",
"err_code_invalid_title": "Koden er ikke gyldig",
"err_code_used_detail": "Hver kode virker bare én gang. Vis en ny i nettposten, og skann på nytt.",
"err_code_used_title": "Koden er allerede brukt",
"err_generic_detail": "Noe gikk galt. Prøv igjen.",
"err_generic_title": "Påloggingen mislyktes",
"err_interrupted_detail": "Svaret samsvarte ikke med forespørselen vi startet. Prøv å logge på igjen.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Ingen e-postserver på {host}",
"err_no_webmail_detail": "Denne serveren har verken Bulwark-nettpost eller OAuth-pålogging. Bruk et passord (eller et app-passord) i stedet.",
"err_no_webmail_title": "Ingen påloggingsside på {host}",
"err_pair_connect_detail": "Vis en ny kode på datamaskinen, og skann på nytt.",
"err_pair_connect_title": "Pålogget, men kunne ikke koble til {host}",
"err_pair_insecure_detail": "{host} vil bruke en ukryptert tilkobling.",
"err_pair_network_detail": "Sjekk tilkoblingen og at telefonen kan nå nettposten, og prøv deretter på nytt.",
"err_pair_rate_detail": "Vent et minutt, og prøv deretter på nytt.",
"err_pair_server_detail": "Prøv på nytt med en ny kode. Hvis det fortsatt mislykkes, kontakt administratoren.",
"err_pair_server_title": "Nettposten kunne ikke fullføre påloggingen",
"err_pair_unsupported_detail": "{host} støtter ikke påloggingskoder. Den må kanskje oppdateres, eller lenken peker til feil adresse.",
"err_pair_unsupported_title": "Denne nettposten kan ikke koble til enheter",
"err_pair_untrusted_detail": "{host} sendte påloggingsinformasjon for en server appen ikke kan bekrefte.",
"err_pair_untrusted_title": "Denne påloggingen kan ikke stoles på",
"err_rate_detail": "Serveren ba oss vente litt. Prøv igjen om et øyeblikk.",
"err_rate_detail_seconds": "Serveren ba oss vente. Prøv igjen om omtrent {seconds} sekunder.",
"err_rate_title": "For mange forsøk",
@@ -487,6 +505,12 @@
"know_server": "Jeg kjenner serveradressen min",
"known_server_desc": "Vi kjenner allerede denne serveren, så du trenger ikke å sette opp noe",
"known_server_title": "En annen konto på {host}",
"link_confirm_connect": "En lenke ber om å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.",
"link_confirm_connect_add": "En lenke ber om å legge til en konto ved å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.",
"link_confirm_connect_title": "Åpne påloggingssiden til {host}?",
"link_confirm_pair": "Denne lenken logger på en konto fra {host}. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.",
"link_confirm_pair_add": "Denne lenken legger til en konto fra {host} og bytter appen til den. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.",
"link_confirm_title": "Logge på med en lenke?",
"looking_up": "Slår opp serveren din…",
"manual": "Skriv inn serverdetaljer manuelt",
"notice_bad_qr": "Koden er ikke en påloggingskode for Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Kobler denne enheten til {host}…",
"password_subtitle": "Passordet ditt sendes bare til denne serveren og lagres i nøkkelringen på enheten.",
"password_title": "Logg på {host}",
"paste_button": "Lim inn",
"paste_clipboard_empty": "Ingenting å lime inn. Kopier lenken i nettposten først.",
"paste_hint": "Åpne Innstillinger → Sikkerhet → Koble til mobilappen i nettposten, kopier påloggingslenken og lim den inn her.",
"paste_invalid": "Det er ikke en påloggingslenke for Bulwark",
"paste_label": "Påloggingslenke",
"paste_title": "Lim inn påloggingslenke",
"provider_detail": "Du skriver det inn på deres side. Bulwark lagrer bare et påloggingstoken, som du når som helst kan tilbakekalle.",
"provider_title": "Passordet ditt blir hos leverandøren",
"qr_hint": "Innstillinger → Sikkerhet → Koble til mobilappen viser en kode. Rett kameraet mot den.",
@@ -515,6 +545,7 @@
"server_hint": "Samme adresse som du skriver inn i nettleseren. Vi legger til https:// for deg.",
"server_label": "Serveradresse",
"server_not_found": "Vi fant ingen server for {domain}. Hvis du drifter din egen server, er det adressen du bruker for nettposten.",
"server_paste_hint": "Har du en påloggingslenke i stedet? Lim den inn her.",
"server_scan_hint": "Hvis du er usikker, kan du skanne en påloggingskode i stedet. Den inneholder serveradressen.",
"server_subtitle": "Skriv inn adressen du bruker for å nå nettposten din.",
"server_title": "Hva er serveradressen din?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Wat is uw e-mailadres?",
"email_step_title_add": "Wat is het andere adres?",
"email_title": "Mijn e-mailadres gebruiken",
"err_account_limit_detail": "Verwijder een account in Instellingen om er nog een toe te voegen.",
"err_bad_detail": "Controleer uw e-mailadres en wachtwoord. Als uw account tweefactorauthenticatie gebruikt, maak dan in de webmail een app-wachtwoord aan en gebruik dat hier.",
"err_bad_title": "Dat is niet gelukt",
"err_cert_detail": "Het beveiligingscertificaat van de server is geweigerd. Als dit uw eigen server is, controleer dan of het certificaat geldig en niet verlopen is.",
"err_cert_title": "Kan {host} niet verifiëren",
"err_code_expired_detail": "Inlogcodes zijn maar een paar minuten geldig. Genereer een nieuwe in de webmail en scan opnieuw.",
"err_code_expired_detail": "Inlogcodes zijn twee minuten geldig. Toon een nieuwe in de webmail en scan opnieuw.",
"err_code_expired_or_used_detail": "Toon een nieuwe in de webmail en scan opnieuw.",
"err_code_expired_or_used_title": "Die code is verlopen of al gebruikt",
"err_code_expired_title": "Die code is verlopen",
"err_code_invalid_detail": "Toon een nieuwe code in de webmail (Instellingen → Beveiliging → Mobiele app koppelen) en scan die.",
"err_code_invalid_title": "Die code is niet geldig",
"err_code_used_detail": "Elke code werkt maar één keer. Toon een nieuwe in de webmail en scan opnieuw.",
"err_code_used_title": "Die code is al gebruikt",
"err_generic_detail": "Er is iets misgegaan. Probeer het opnieuw.",
"err_generic_title": "Inloggen mislukt",
"err_interrupted_detail": "Het antwoord kwam niet overeen met het gestarte verzoek. Probeer opnieuw in te loggen.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Geen mailserver op {host}",
"err_no_webmail_detail": "Deze server heeft geen Bulwark-webmail en geen OAuth-aanmelding. Gebruik in plaats daarvan een wachtwoord (of een app-wachtwoord).",
"err_no_webmail_title": "Geen inlogpagina op {host}",
"err_pair_connect_detail": "Toon een nieuwe code op uw computer en scan opnieuw.",
"err_pair_connect_title": "Ingelogd, maar kan geen verbinding maken met {host}",
"err_pair_insecure_detail": "{host} wil een onversleutelde verbinding gebruiken.",
"err_pair_network_detail": "Controleer uw verbinding en of uw telefoon de webmail kan bereiken, en probeer het opnieuw.",
"err_pair_rate_detail": "Wacht een minuut en probeer het dan opnieuw.",
"err_pair_server_detail": "Probeer het opnieuw met een nieuwe code. Blijft het mislukken, neem dan contact op met uw beheerder.",
"err_pair_server_title": "De webmail kon het inloggen niet voltooien",
"err_pair_unsupported_detail": "{host} ondersteunt geen inlogcodes. Mogelijk is een update nodig, of de link verwijst naar het verkeerde adres.",
"err_pair_unsupported_title": "Deze webmail kan geen apparaten koppelen",
"err_pair_untrusted_detail": "{host} heeft inloggegevens gestuurd voor een server die de app niet kan verifiëren.",
"err_pair_untrusted_title": "Deze aanmelding kan niet worden vertrouwd",
"err_rate_detail": "De server vroeg ons even te wachten. Probeer het zo opnieuw.",
"err_rate_detail_seconds": "De server vroeg ons te wachten. Probeer het over ongeveer {seconds} seconden opnieuw.",
"err_rate_title": "Te veel pogingen",
@@ -487,6 +505,12 @@
"know_server": "Ik ken mijn serveradres",
"known_server_desc": "We kennen deze server al, dus er hoeft niets te worden ingesteld",
"known_server_title": "Nog een account op {host}",
"link_confirm_connect": "Een link vraagt om op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.",
"link_confirm_connect_add": "Een link vraagt om een account toe te voegen door op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.",
"link_confirm_connect_title": "Inlogpagina van {host} openen?",
"link_confirm_pair": "Deze link logt u in op een account van {host}. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.",
"link_confirm_pair_add": "Deze link voegt een account van {host} toe en schakelt de app daarnaar over. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.",
"link_confirm_title": "Inloggen met een link?",
"looking_up": "Uw server opzoeken…",
"manual": "Servergegevens handmatig invoeren",
"notice_bad_qr": "Die code is geen Bulwark-inlogcode",
@@ -505,6 +529,12 @@
"pairing_detail": "Dit apparaat wordt gekoppeld aan {host}…",
"password_subtitle": "Uw wachtwoord wordt alleen naar deze server verzonden en opgeslagen in de sleutelhanger van het apparaat.",
"password_title": "Inloggen bij {host}",
"paste_button": "Plakken",
"paste_clipboard_empty": "Niets om te plakken. Kopieer eerst de link in de webmail.",
"paste_hint": "Open in de webmail Instellingen → Beveiliging → Mobiele app koppelen, kopieer de inloglink en plak die hier.",
"paste_invalid": "Dat is geen Bulwark-inloglink",
"paste_label": "Inloglink",
"paste_title": "Inloglink plakken",
"provider_detail": "U typt het op hun pagina. Bulwark bewaart alleen een inlogtoken, dat u op elk moment kunt intrekken.",
"provider_title": "Uw wachtwoord blijft bij uw provider",
"qr_hint": "Instellingen → Beveiliging → Mobiele app koppelen toont een code. Richt de camera erop.",
@@ -515,6 +545,7 @@
"server_hint": "Hetzelfde adres dat u in de browser typt. We voegen https:// voor u toe.",
"server_label": "Serveradres",
"server_not_found": "We konden geen server vinden voor {domain}. Als u zelf host, is het het adres dat u voor de webmail gebruikt.",
"server_paste_hint": "Hebt u in plaats daarvan een inloglink? Plak die hier.",
"server_scan_hint": "Weet u het niet zeker? Scan dan een inlogcode. Die bevat het serveradres.",
"server_subtitle": "Voer het adres in waarmee u uw webmail bereikt.",
"server_title": "Wat is uw serveradres?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Jaki jest Twój adres e-mail?",
"email_step_title_add": "Jaki jest drugi adres?",
"email_title": "Użyj mojego adresu e-mail",
"err_account_limit_detail": "Usuń konto w Ustawieniach, aby dodać kolejne.",
"err_bad_detail": "Sprawdź adres e-mail i hasło. Jeśli Twoje konto używa logowania dwuskładnikowego, utwórz hasło aplikacji w webmailu i użyj go tutaj.",
"err_bad_title": "To nie zadziałało",
"err_cert_detail": "Certyfikat bezpieczeństwa serwera został odrzucony. Jeśli to Twój własny serwer, sprawdź, czy certyfikat jest ważny i nie wygasł.",
"err_cert_title": "Nie można zweryfikować: {host}",
"err_code_expired_detail": "Kody logowania są ważne przez kilka minut. Wygeneruj nowy w webmailu i zeskanuj go ponownie.",
"err_code_expired_detail": "Kody logowania są ważne przez dwie minuty. Wyświetl nowy w webmailu i zeskanuj go ponownie.",
"err_code_expired_or_used_detail": "Wyświetl nowy w webmailu i zeskanuj go ponownie.",
"err_code_expired_or_used_title": "Ten kod wygasł lub został już użyty",
"err_code_expired_title": "Ten kod wygasł",
"err_code_invalid_detail": "Wyświetl nowy kod w webmailu (Ustawienia → Bezpieczeństwo → Połącz aplikację mobilną) i zeskanuj go.",
"err_code_invalid_title": "Ten kod jest nieprawidłowy",
"err_code_used_detail": "Każdy kod działa tylko raz. Wyświetl nowy w webmailu i zeskanuj go ponownie.",
"err_code_used_title": "Ten kod został już użyty",
"err_generic_detail": "Coś poszło nie tak. Spróbuj ponownie.",
"err_generic_title": "Logowanie nie powiodło się",
"err_interrupted_detail": "Odpowiedź nie pasuje do rozpoczętego żądania. Spróbuj zalogować się ponownie.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Brak serwera poczty: {host}",
"err_no_webmail_detail": "Ten serwer nie ma webmaila Bulwark ani logowania OAuth. Użyj hasła (lub hasła aplikacji).",
"err_no_webmail_title": "Brak strony logowania: {host}",
"err_pair_connect_detail": "Wyświetl nowy kod na komputerze i zeskanuj go ponownie.",
"err_pair_connect_title": "Zalogowano, ale nie można połączyć się z {host}",
"err_pair_insecure_detail": "{host} chce użyć nieszyfrowanego połączenia.",
"err_pair_network_detail": "Sprawdź połączenie i czy telefon ma dostęp do webmaila, a potem spróbuj ponownie.",
"err_pair_rate_detail": "Poczekaj minutę i spróbuj ponownie.",
"err_pair_server_detail": "Spróbuj ponownie z nowym kodem. Jeśli nadal się nie udaje, skontaktuj się z administratorem.",
"err_pair_server_title": "Webmail nie mógł dokończyć logowania",
"err_pair_unsupported_detail": "{host} nie obsługuje kodów logowania. Może wymagać aktualizacji albo link wskazuje zły adres.",
"err_pair_unsupported_title": "Ten webmail nie może łączyć urządzeń",
"err_pair_untrusted_detail": "{host} przesłał dane logowania dla serwera, którego aplikacja nie może zweryfikować.",
"err_pair_untrusted_title": "Temu logowaniu nie można zaufać",
"err_rate_detail": "Serwer poprosił o chwilę przerwy. Spróbuj ponownie za moment.",
"err_rate_detail_seconds": "Serwer poprosił o odczekanie. Spróbuj ponownie za około {seconds} s.",
"err_rate_title": "Zbyt wiele prób",
@@ -487,6 +505,12 @@
"know_server": "Znam adres swojego serwera",
"known_server_desc": "Ten serwer jest już znany, więc nie trzeba niczego konfigurować",
"known_server_title": "Kolejne konto na {host}",
"link_confirm_connect": "Link prosi o zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.",
"link_confirm_connect_add": "Link prosi o dodanie konta przez zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.",
"link_confirm_connect_title": "Otworzyć stronę logowania {host}?",
"link_confirm_pair": "Ten link loguje do konta z {host}. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.",
"link_confirm_pair_add": "Ten link dodaje konto z {host} i przełącza na nie aplikację. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.",
"link_confirm_title": "Zalogować się za pomocą linku?",
"looking_up": "Wyszukiwanie serwera…",
"manual": "Wprowadź dane serwera ręcznie",
"notice_bad_qr": "To nie jest kod logowania Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Parowanie tego urządzenia: {host}…",
"password_subtitle": "Hasło jest wysyłane tylko do tego serwera i przechowywane w magazynie kluczy urządzenia.",
"password_title": "Zaloguj się do {host}",
"paste_button": "Wklej",
"paste_clipboard_empty": "Nie ma nic do wklejenia. Najpierw skopiuj link w webmailu.",
"paste_hint": "W webmailu otwórz Ustawienia → Bezpieczeństwo → Połącz aplikację mobilną, skopiuj link logowania i wklej go tutaj.",
"paste_invalid": "To nie jest link logowania Bulwark",
"paste_label": "Link logowania",
"paste_title": "Wklej link logowania",
"provider_detail": "Wpiszesz je na stronie dostawcy. Bulwark przechowuje tylko token logowania, który możesz w każdej chwili unieważnić.",
"provider_title": "Twoje hasło zostaje u dostawcy",
"qr_hint": "W Ustawienia → Bezpieczeństwo → Połącz aplikację mobilną wyświetla się kod. Skieruj na niego aparat.",
@@ -515,6 +545,7 @@
"server_hint": "Ten sam adres, który wpisujesz w przeglądarce. Dodamy https:// za Ciebie.",
"server_label": "Adres serwera",
"server_not_found": "Nie znaleźliśmy serwera dla {domain}. Jeśli masz własny serwer, podaj adres, którego używasz do webmaila.",
"server_paste_hint": "Masz zamiast tego link logowania? Wklej go tutaj.",
"server_scan_hint": "Jeśli nie masz pewności, zeskanuj kod logowania. Zawiera on adres serwera.",
"server_subtitle": "Wpisz adres, pod którym otwierasz webmail.",
"server_title": "Jaki jest adres Twojego serwera?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Qual é o seu endereço de e-mail?",
"email_step_title_add": "Qual é o outro endereço?",
"email_title": "Usar meu endereço de e-mail",
"err_account_limit_detail": "Remova uma conta em Configurações para adicionar outra.",
"err_bad_detail": "Verifique seu e-mail e sua senha. Se sua conta usa autenticação de dois fatores, crie uma senha de aplicativo no webmail e use-a aqui.",
"err_bad_title": "Não funcionou",
"err_cert_detail": "O certificado de segurança do servidor foi rejeitado. Se o servidor for seu, verifique se o certificado é válido e não expirou.",
"err_cert_title": "Não foi possível verificar {host}",
"err_code_expired_detail": "Os códigos de login valem por alguns minutos. Gere um novo no webmail e escaneie de novo.",
"err_code_expired_detail": "Os códigos de login valem por dois minutos. Mostre um novo no webmail e escaneie de novo.",
"err_code_expired_or_used_detail": "Mostre um novo no webmail e escaneie de novo.",
"err_code_expired_or_used_title": "Esse código expirou ou já foi usado",
"err_code_expired_title": "Esse código expirou",
"err_code_invalid_detail": "Mostre um novo código no webmail (Configurações → Segurança → Vincular aplicativo móvel) e escaneie-o.",
"err_code_invalid_title": "Esse código não é válido",
"err_code_used_detail": "Cada código funciona uma única vez. Mostre um novo no webmail e escaneie de novo.",
"err_code_used_title": "Esse código já foi usado",
"err_generic_detail": "Algo deu errado. Tente novamente.",
"err_generic_title": "Falha no login",
"err_interrupted_detail": "A resposta não corresponde à solicitação que iniciamos. Tente entrar novamente.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Nenhum servidor de e-mail em {host}",
"err_no_webmail_detail": "Este servidor não tem webmail do Bulwark nem login OAuth. Use uma senha (ou uma senha de aplicativo).",
"err_no_webmail_title": "Nenhuma página de login em {host}",
"err_pair_connect_detail": "Mostre um novo código no computador e escaneie de novo.",
"err_pair_connect_title": "Login feito, mas não foi possível conectar a {host}",
"err_pair_insecure_detail": "{host} quer usar uma conexão sem criptografia.",
"err_pair_network_detail": "Verifique sua conexão e se o celular consegue acessar o webmail, depois tente de novo.",
"err_pair_rate_detail": "Aguarde um minuto e tente de novo.",
"err_pair_server_detail": "Tente de novo com um novo código. Se continuar falhando, fale com o administrador.",
"err_pair_server_title": "O webmail não conseguiu concluir o login",
"err_pair_unsupported_detail": "{host} não oferece suporte a códigos de login. Talvez precise de uma atualização, ou o link aponta para o endereço errado.",
"err_pair_unsupported_title": "Este webmail não consegue vincular dispositivos",
"err_pair_untrusted_detail": "{host} enviou dados de login para um servidor que o aplicativo não consegue verificar.",
"err_pair_untrusted_title": "Este login não é confiável",
"err_rate_detail": "O servidor pediu para aguardarmos um momento. Tente novamente em breve.",
"err_rate_detail_seconds": "O servidor pediu para aguardarmos. Tente novamente em cerca de {seconds} segundos.",
"err_rate_title": "Tentativas demais",
@@ -487,6 +505,12 @@
"know_server": "Sei o endereço do meu servidor",
"known_server_desc": "Já conhecemos este servidor, então não há nada para configurar",
"known_server_title": "Outra conta em {host}",
"link_confirm_connect": "Um link pede para fazer login nesta página. Só continue se você usa {host} para o seu e-mail.",
"link_confirm_connect_add": "Um link pede para adicionar uma conta fazendo login nesta página. Só continue se você usa {host} para o seu e-mail.",
"link_confirm_connect_title": "Abrir a página de login de {host}?",
"link_confirm_pair": "Este link faz login em uma conta de {host}. Só continue se você acabou de mostrar um código de login no computador.",
"link_confirm_pair_add": "Este link adiciona uma conta de {host} e muda o aplicativo para ela. Só continue se você acabou de mostrar um código de login no computador.",
"link_confirm_title": "Entrar com um link?",
"looking_up": "Procurando seu servidor…",
"manual": "Inserir os dados do servidor manualmente",
"notice_bad_qr": "Esse código não é um código de login do Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Pareando este dispositivo com {host}…",
"password_subtitle": "Sua senha é enviada apenas para este servidor e fica guardada no armazenamento seguro do dispositivo.",
"password_title": "Entrar em {host}",
"paste_button": "Colar",
"paste_clipboard_empty": "Nada para colar. Copie o link no webmail primeiro.",
"paste_hint": "No webmail, abra Configurações → Segurança → Vincular aplicativo móvel, copie o link de login e cole aqui.",
"paste_invalid": "Isso não é um link de login do Bulwark",
"paste_label": "Link de login",
"paste_title": "Colar link de login",
"provider_detail": "Você vai digitá-la na página deles. O Bulwark armazena apenas um token de login, que você pode revogar a qualquer momento.",
"provider_title": "Sua senha fica com seu provedor",
"qr_hint": "Configurações → Segurança → Vincular aplicativo móvel mostra um código. Aponte a câmera para ele.",
@@ -515,6 +545,7 @@
"server_hint": "O mesmo endereço que você digita no navegador. Adicionamos o https:// para você.",
"server_label": "Endereço do servidor",
"server_not_found": "Não encontramos um servidor para {domain}. Se você hospeda o seu próprio, o endereço é o que você usa para o webmail.",
"server_paste_hint": "Tem um link de login? Cole aqui.",
"server_scan_hint": "Se não tiver certeza, escaneie um código de login. Ele contém o endereço do servidor.",
"server_subtitle": "Digite o endereço que você usa para acessar seu webmail.",
"server_title": "Qual é o endereço do seu servidor?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Care este adresa dvs. de e-mail?",
"email_step_title_add": "Care este cealaltă adresă?",
"email_title": "Folosiți adresa mea de e-mail",
"err_account_limit_detail": "Eliminați un cont din Setări pentru a adăuga altul.",
"err_bad_detail": "Verificați adresa de e-mail și parola. Dacă contul dvs. folosește autentificarea în doi pași, creați o parolă pentru aplicații în webmail și folosiți-o aici.",
"err_bad_title": "Nu a funcționat",
"err_cert_detail": "Certificatul de securitate al serverului a fost respins. Dacă este serverul dvs., verificați dacă certificatul este valid și nu a expirat.",
"err_cert_title": "Nu s-a putut verifica {host}",
"err_code_expired_detail": "Codurile de autentificare sunt valabile câteva minute. Generați unul nou în webmail și scanați-l din nou.",
"err_code_expired_detail": "Codurile de autentificare sunt valabile două minute. Afișați unul nou în webmail și scanați-l din nou.",
"err_code_expired_or_used_detail": "Afișați unul nou în webmail și scanați-l din nou.",
"err_code_expired_or_used_title": "Acest cod a expirat sau a fost deja folosit",
"err_code_expired_title": "Acest cod a expirat",
"err_code_invalid_detail": "Afișați un cod nou în webmail (Setări → Securitate → Aplicație mobilă) și scanați-l.",
"err_code_invalid_title": "Acest cod nu este valid",
"err_code_used_detail": "Fiecare cod funcționează o singură dată. Afișați unul nou în webmail și scanați-l din nou.",
"err_code_used_title": "Acest cod a fost deja folosit",
"err_generic_detail": "Ceva nu a funcționat. Încercați din nou.",
"err_generic_title": "Autentificarea a eșuat",
"err_interrupted_detail": "Răspunsul nu corespunde cererii pe care am inițiat-o. Încercați să vă autentificați din nou.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Niciun server de e-mail la {host}",
"err_no_webmail_detail": "Acest server nu are webmail Bulwark și nici autentificare OAuth. Folosiți în schimb o parolă (sau o parolă pentru aplicații).",
"err_no_webmail_title": "Nicio pagină de autentificare la {host}",
"err_pair_connect_detail": "Afișați un cod nou pe computer și scanați-l din nou.",
"err_pair_connect_title": "Autentificat, dar nu se poate conecta la {host}",
"err_pair_insecure_detail": "{host} vrea să folosească o conexiune necriptată.",
"err_pair_network_detail": "Verificați conexiunea și dacă telefonul poate accesa webmailul, apoi încercați din nou.",
"err_pair_rate_detail": "Așteptați un minut, apoi încercați din nou.",
"err_pair_server_detail": "Încercați din nou cu un cod nou. Dacă tot nu funcționează, contactați administratorul.",
"err_pair_server_title": "Webmailul nu a putut finaliza autentificarea",
"err_pair_unsupported_detail": "{host} nu acceptă coduri de autentificare. Poate are nevoie de o actualizare sau linkul duce la o adresă greșită.",
"err_pair_unsupported_title": "Acest webmail nu poate asocia dispozitive",
"err_pair_untrusted_detail": "{host} a trimis date de autentificare pentru un server pe care aplicația nu îl poate verifica.",
"err_pair_untrusted_title": "Această autentificare nu este de încredere",
"err_rate_detail": "Serverul ne-a cerut să așteptăm puțin. Încercați din nou în scurt timp.",
"err_rate_detail_seconds": "Serverul ne-a cerut să așteptăm. Încercați din nou în aproximativ {seconds, plural, one {# secundă} few {# secunde} other {# de secunde}}.",
"err_rate_title": "Prea multe încercări",
@@ -487,6 +505,12 @@
"know_server": "Cunosc adresa serverului meu",
"known_server_desc": "Cunoaștem deja acest server, deci nu este nimic de configurat",
"known_server_title": "Alt cont pe {host}",
"link_confirm_connect": "Un link cere autentificarea pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.",
"link_confirm_connect_add": "Un link cere adăugarea unui cont prin autentificare pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.",
"link_confirm_connect_title": "Deschideți pagina de autentificare a {host}?",
"link_confirm_pair": "Acest link vă autentifică într-un cont de la {host}. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.",
"link_confirm_pair_add": "Acest link adaugă un cont de la {host} și comută aplicația pe el. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.",
"link_confirm_title": "Vă autentificați cu un link?",
"looking_up": "Se caută serverul dvs.…",
"manual": "Introduceți manual detaliile serverului",
"notice_bad_qr": "Acesta nu este un cod de autentificare Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Se asociază acest dispozitiv cu {host}…",
"password_subtitle": "Parola dvs. este trimisă doar către acest server și este stocată în spațiul securizat al dispozitivului.",
"password_title": "Autentificare la {host}",
"paste_button": "Lipiți",
"paste_clipboard_empty": "Nu este nimic de lipit. Copiați mai întâi linkul din webmail.",
"paste_hint": "În webmail, deschideți Setări → Securitate → Aplicație mobilă, copiați linkul de autentificare și lipiți-l aici.",
"paste_invalid": "Acesta nu este un link de autentificare Bulwark",
"paste_label": "Link de autentificare",
"paste_title": "Lipiți linkul de autentificare",
"provider_detail": "O veți introduce pe pagina lor. Bulwark stochează doar un token de autentificare, pe care îl puteți revoca oricând.",
"provider_title": "Parola dvs. rămâne la furnizor",
"qr_hint": "Setări → Securitate → Aplicație mobilă afișează un cod. Îndreptați camera spre el.",
@@ -515,6 +545,7 @@
"server_hint": "Aceeași adresă pe care o introduceți în browser. Adăugăm noi https://.",
"server_label": "Adresa serverului",
"server_not_found": "Nu am găsit un server pentru {domain}. Dacă îl găzduiți chiar dvs., adresa este cea pe care o folosiți pentru webmail.",
"server_paste_hint": "Aveți în schimb un link de autentificare? Lipiți-l aici.",
"server_scan_hint": "Dacă nu sunteți sigur, scanați în schimb un cod de autentificare. Acesta conține adresa serverului.",
"server_subtitle": "Introduceți adresa pe care o folosiți pentru a accesa webmailul.",
"server_title": "Care este adresa serverului dvs.?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Какой у вас адрес электронной почты?",
"email_step_title_add": "Какой у вас другой адрес?",
"email_title": "Использовать мой адрес электронной почты",
"err_account_limit_detail": "Удалите аккаунт в настройках, чтобы добавить новый.",
"err_bad_detail": "Проверьте адрес электронной почты и пароль. Если в аккаунте включена двухфакторная аутентификация, создайте пароль приложения в веб-почте и используйте его здесь.",
"err_bad_title": "Не получилось",
"err_cert_detail": "Сертификат безопасности сервера отклонён. Если это ваш собственный сервер, проверьте, что сертификат действителен и не истёк.",
"err_cert_title": "Не удалось проверить {host}",
"err_code_expired_detail": "Коды входа действуют несколько минут. Создайте новый в веб-почте и отсканируйте его снова.",
"err_code_expired_detail": "Коды входа действуют две минуты. Покажите новый в веб-почте и отсканируйте его снова.",
"err_code_expired_or_used_detail": "Покажите новый в веб-почте и отсканируйте его снова.",
"err_code_expired_or_used_title": "Срок действия кода истёк, или он уже использован",
"err_code_expired_title": "Срок действия кода истёк",
"err_code_invalid_detail": "Покажите новый код в веб-почте (Настройки → Безопасность → Привязать мобильное приложение) и отсканируйте его.",
"err_code_invalid_title": "Этот код недействителен",
"err_code_used_detail": "Каждый код работает только один раз. Покажите новый в веб-почте и отсканируйте его снова.",
"err_code_used_title": "Этот код уже использован",
"err_generic_detail": "Что-то пошло не так. Попробуйте ещё раз.",
"err_generic_title": "Не удалось войти",
"err_interrupted_detail": "Ответ не соответствует начатому запросу. Попробуйте войти ещё раз.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Почтовый сервер не найден: {host}",
"err_no_webmail_detail": "На этом сервере нет веб-почты Bulwark и входа через OAuth. Используйте пароль (или пароль приложения).",
"err_no_webmail_title": "Нет страницы входа: {host}",
"err_pair_connect_detail": "Покажите новый код на компьютере и отсканируйте его снова.",
"err_pair_connect_title": "Вход выполнен, но не удалось подключиться к {host}",
"err_pair_insecure_detail": "{host} хочет использовать незашифрованное соединение.",
"err_pair_network_detail": "Проверьте подключение и доступна ли веб-почта с телефона, затем повторите попытку.",
"err_pair_rate_detail": "Подождите минуту и повторите попытку.",
"err_pair_server_detail": "Попробуйте ещё раз с новым кодом. Если ошибка повторяется, обратитесь к администратору.",
"err_pair_server_title": "Веб-почте не удалось завершить вход",
"err_pair_unsupported_detail": "{host} не поддерживает коды входа. Возможно, нужно обновление, или ссылка ведёт на неверный адрес.",
"err_pair_unsupported_title": "Эта веб-почта не может привязывать устройства",
"err_pair_untrusted_detail": "{host} отправил данные для входа на сервер, который приложение не может проверить.",
"err_pair_untrusted_title": "Этому входу нельзя доверять",
"err_rate_detail": "Сервер попросил немного подождать. Повторите попытку чуть позже.",
"err_rate_detail_seconds": "Сервер попросил подождать. Повторите попытку примерно через {seconds} с.",
"err_rate_title": "Слишком много попыток",
@@ -487,6 +505,12 @@
"know_server": "Я знаю адрес своего сервера",
"known_server_desc": "Этот сервер уже известен, настройка не нужна",
"known_server_title": "Ещё один аккаунт на {host}",
"link_confirm_connect": "Ссылка предлагает войти на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.",
"link_confirm_connect_add": "Ссылка предлагает добавить аккаунт, войдя на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.",
"link_confirm_connect_title": "Открыть страницу входа {host}?",
"link_confirm_pair": "Эта ссылка выполняет вход в аккаунт на {host}. Продолжайте, только если вы только что показали код входа на своём компьютере.",
"link_confirm_pair_add": "Эта ссылка добавляет аккаунт с {host} и переключает на него приложение. Продолжайте, только если вы только что показали код входа на своём компьютере.",
"link_confirm_title": "Войти по ссылке?",
"looking_up": "Ищем ваш сервер…",
"manual": "Ввести данные сервера вручную",
"notice_bad_qr": "Это не код входа Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Привязка устройства: {host}…",
"password_subtitle": "Пароль отправляется только на этот сервер и хранится в защищённом хранилище устройства.",
"password_title": "Вход на {host}",
"paste_button": "Вставить",
"paste_clipboard_empty": "Нечего вставлять. Сначала скопируйте ссылку в веб-почте.",
"paste_hint": "В веб-почте откройте Настройки → Безопасность → Привязать мобильное приложение, скопируйте ссылку для входа и вставьте её сюда.",
"paste_invalid": "Это не ссылка для входа Bulwark",
"paste_label": "Ссылка для входа",
"paste_title": "Вставить ссылку для входа",
"provider_detail": "Вы введёте его на их странице. Bulwark хранит только токен входа, который можно отозвать в любой момент.",
"provider_title": "Ваш пароль остаётся у провайдера",
"qr_hint": "В разделе Настройки → Безопасность → Привязать мобильное приложение появится код. Наведите на него камеру.",
@@ -515,6 +545,7 @@
"server_hint": "Тот же адрес, что вы вводите в браузере. Мы сами добавим https://.",
"server_label": "Адрес сервера",
"server_not_found": "Не удалось найти сервер для {domain}. Если у вас свой сервер, укажите адрес, по которому вы открываете веб-почту.",
"server_paste_hint": "У вас есть ссылка для входа? Вставьте её сюда.",
"server_scan_hint": "Если не уверены, отсканируйте код входа — в нём есть адрес сервера.",
"server_subtitle": "Введите адрес, по которому вы открываете веб-почту.",
"server_title": "Какой адрес у вашего сервера?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Aká je vaša e-mailová adresa?",
"email_step_title_add": "Aká je tá druhá adresa?",
"email_title": "Použiť moju e-mailovú adresu",
"err_account_limit_detail": "Ak chcete pridať ďalší účet, odstráňte niektorý v Nastaveniach.",
"err_bad_detail": "Skontrolujte e-mail a heslo. Ak váš účet používa dvojfaktorové prihlásenie, vytvorte vo webmaile heslo aplikácie a použite ho tu.",
"err_bad_title": "To nevyšlo",
"err_cert_detail": "Bezpečnostný certifikát servera bol odmietnutý. Ak ide o váš vlastný server, skontrolujte, či je certifikát platný a nevypršal.",
"err_cert_title": "{host} sa nepodarilo overiť",
"err_code_expired_detail": "Prihlasovacie kódy platia niekoľko minút. Vygenerujte vo webmaile nový a naskenujte ho znova.",
"err_code_expired_detail": "Prihlasovacie kódy platia dve minúty. Zobrazte vo webmaile nový a naskenujte ho znova.",
"err_code_expired_or_used_detail": "Zobrazte vo webmaile nový a naskenujte ho znova.",
"err_code_expired_or_used_title": "Platnosť kódu vypršala alebo už bol použitý",
"err_code_expired_title": "Platnosť kódu vypršala",
"err_code_invalid_detail": "Zobrazte vo webmaile nový kód (Nastavenia → Zabezpečenie → Prepojiť mobilnú aplikáciu) a naskenujte ho.",
"err_code_invalid_title": "Tento kód nie je platný",
"err_code_used_detail": "Každý kód funguje len raz. Zobrazte vo webmaile nový a naskenujte ho znova.",
"err_code_used_title": "Tento kód už bol použitý",
"err_generic_detail": "Niečo sa pokazilo. Skúste to znova.",
"err_generic_title": "Prihlásenie zlyhalo",
"err_interrupted_detail": "Odpoveď nezodpovedala začatej požiadavke. Skúste sa prihlásiť znova.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Poštový server sa nenašiel: {host}",
"err_no_webmail_detail": "Tento server nemá webmail Bulwark ani prihlásenie cez OAuth. Použite heslo (alebo heslo aplikácie).",
"err_no_webmail_title": "Prihlasovacia stránka sa nenašla: {host}",
"err_pair_connect_detail": "Zobrazte na počítači nový kód a naskenujte ho znova.",
"err_pair_connect_title": "Prihlásené, ale nedá sa pripojiť k {host}",
"err_pair_insecure_detail": "{host} chce použiť nešifrované pripojenie.",
"err_pair_network_detail": "Skontrolujte pripojenie a či sa telefón dostane k webmailu, a skúste to znova.",
"err_pair_rate_detail": "Počkajte minútu a skúste to znova.",
"err_pair_server_detail": "Skúste to znova s novým kódom. Ak to stále zlyháva, obráťte sa na správcu.",
"err_pair_server_title": "Webmail nedokázal dokončiť prihlásenie",
"err_pair_unsupported_detail": "{host} nepodporuje prihlasovacie kódy. Možno potrebuje aktualizáciu, alebo odkaz vedie na nesprávnu adresu.",
"err_pair_unsupported_title": "Tento webmail nedokáže prepájať zariadenia",
"err_pair_untrusted_detail": "{host} poslal prihlasovacie údaje pre server, ktorý aplikácia nedokáže overiť.",
"err_pair_untrusted_title": "Tomuto prihláseniu nemožno dôverovať",
"err_rate_detail": "Server nás požiadal o chvíľu strpenia. Skúste to o chvíľu znova.",
"err_rate_detail_seconds": "Server nás požiadal o strpenie. Skúste to znova približne o {seconds} s.",
"err_rate_title": "Príliš veľa pokusov",
@@ -487,6 +505,12 @@
"know_server": "Poznám adresu svojho servera",
"known_server_desc": "Tento server už poznáme, takže nie je potrebné nič nastavovať",
"known_server_title": "Ďalší účet na {host}",
"link_confirm_connect": "Odkaz žiada o prihlásenie na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.",
"link_confirm_connect_add": "Odkaz žiada o pridanie účtu prihlásením na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.",
"link_confirm_connect_title": "Otvoriť prihlasovaciu stránku {host}?",
"link_confirm_pair": "Tento odkaz vás prihlási do účtu z {host}. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.",
"link_confirm_pair_add": "Tento odkaz pridá účet z {host} a prepne naň aplikáciu. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.",
"link_confirm_title": "Prihlásiť sa pomocou odkazu?",
"looking_up": "Hľadá sa váš server…",
"manual": "Zadať údaje servera ručne",
"notice_bad_qr": "Toto nie je prihlasovací kód Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Párovanie tohto zariadenia: {host}…",
"password_subtitle": "Heslo sa odošle iba tomuto serveru a uloží sa do úložiska kľúčov v zariadení.",
"password_title": "Prihlásiť sa do {host}",
"paste_button": "Vložiť",
"paste_clipboard_empty": "Nie je čo vložiť. Najprv skopírujte odkaz vo webmaile.",
"paste_hint": "Vo webmaile otvorte Nastavenia → Zabezpečenie → Prepojiť mobilnú aplikáciu, skopírujte prihlasovací odkaz a vložte ho sem.",
"paste_invalid": "Toto nie je prihlasovací odkaz Bulwark",
"paste_label": "Prihlasovací odkaz",
"paste_title": "Vložiť prihlasovací odkaz",
"provider_detail": "Zadáte ho na jeho stránke. Bulwark ukladá iba prihlasovací token, ktorý môžete kedykoľvek odvolať.",
"provider_title": "Vaše heslo zostáva u poskytovateľa",
"qr_hint": "V Nastavenia → Zabezpečenie → Prepojiť mobilnú aplikáciu sa zobrazí kód. Namierte naň fotoaparát.",
@@ -515,6 +545,7 @@
"server_hint": "Rovnaká adresa, akú zadávate v prehliadači. https:// doplníme za vás.",
"server_label": "Adresa servera",
"server_not_found": "Pre {domain} sme nenašli žiadny server. Ak prevádzkujete vlastný server, zadajte adresu, ktorú používate pre webmail.",
"server_paste_hint": "Máte namiesto toho prihlasovací odkaz? Vložte ho sem.",
"server_scan_hint": "Ak si nie ste istí, naskenujte radšej prihlasovací kód. Obsahuje adresu servera.",
"server_subtitle": "Zadajte adresu, cez ktorú otvárate webmail.",
"server_title": "Aká je adresa vášho servera?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "E-posta adresiniz nedir?",
"email_step_title_add": "Diğer adres nedir?",
"email_title": "E-posta adresimi kullan",
"err_account_limit_detail": "Başka bir hesap eklemek için Ayarlar'dan bir hesabı kaldırın.",
"err_bad_detail": "E-posta adresinizi ve parolanızı kontrol edin. Hesabınız iki aşamalı giriş kullanıyorsa webmail'de bir uygulama parolası oluşturun ve burada onu kullanın.",
"err_bad_title": "Bu işe yaramadı",
"err_cert_detail": "Sunucunun güvenlik sertifikası reddedildi. Bu sizin sunucunuzsa sertifikanın geçerli olduğunu ve süresinin dolmadığını kontrol edin.",
"err_cert_title": "{host} doğrulanamadı",
"err_code_expired_detail": "Giriş kodları birkaç dakika geçerlidir. Webmail'de yeni bir kod oluşturup tekrar tarayın.",
"err_code_expired_detail": "Giriş kodları iki dakika geçerlidir. Webmail'de yeni bir kod gösterip tekrar tarayın.",
"err_code_expired_or_used_detail": "Webmail'de yeni bir kod gösterip tekrar tarayın.",
"err_code_expired_or_used_title": "Bu kodun süresi doldu veya kod zaten kullanıldı",
"err_code_expired_title": "Bu kodun süresi doldu",
"err_code_invalid_detail": "Webmail'de yeni bir kod gösterin (Ayarlar → Güvenlik → Mobil uygulamayı bağla) ve tarayın.",
"err_code_invalid_title": "Bu kod geçerli değil",
"err_code_used_detail": "Her kod yalnızca bir kez çalışır. Webmail'de yeni bir kod gösterip tekrar tarayın.",
"err_code_used_title": "Bu kod zaten kullanıldı",
"err_generic_detail": "Bir şeyler ters gitti. Tekrar deneyin.",
"err_generic_title": "Giriş başarısız",
"err_interrupted_detail": "Yanıt, başlattığımız istekle eşleşmedi. Tekrar giriş yapmayı deneyin.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Posta sunucusu bulunamadı: {host}",
"err_no_webmail_detail": "Bu sunucuda Bulwark webmail ve OAuth girişi yok. Bunun yerine parola (veya uygulama parolası) kullanın.",
"err_no_webmail_title": "Giriş sayfası bulunamadı: {host}",
"err_pair_connect_detail": "Bilgisayarınızda yeni bir kod gösterip tekrar tarayın.",
"err_pair_connect_title": "Giriş yapıldı ancak {host} ile bağlantı kurulamadı",
"err_pair_insecure_detail": "{host} şifrelenmemiş bir bağlantı kullanmak istiyor.",
"err_pair_network_detail": "Bağlantınızı ve telefonunuzun webmail'e erişip erişemediğini kontrol edin, ardından tekrar deneyin.",
"err_pair_rate_detail": "Bir dakika bekleyip tekrar deneyin.",
"err_pair_server_detail": "Yeni bir kodla tekrar deneyin. Sorun devam ederse yöneticinize başvurun.",
"err_pair_server_title": "Webmail girişi tamamlayamadı",
"err_pair_unsupported_detail": "{host} giriş kodlarını desteklemiyor. Güncellenmesi gerekiyor olabilir ya da bağlantı yanlış adrese işaret ediyor.",
"err_pair_unsupported_title": "Bu webmail cihaz bağlayamıyor",
"err_pair_untrusted_detail": "{host}, uygulamanın doğrulayamadığı bir sunucu için giriş bilgileri gönderdi.",
"err_pair_untrusted_title": "Bu girişe güvenilemez",
"err_rate_detail": "Sunucu biraz beklememizi istedi. Kısa süre sonra tekrar deneyin.",
"err_rate_detail_seconds": "Sunucu beklememizi istedi. Yaklaşık {seconds} saniye sonra tekrar deneyin.",
"err_rate_title": "Çok fazla deneme",
@@ -487,6 +505,12 @@
"know_server": "Sunucu adresimi biliyorum",
"known_server_desc": "Bu sunucuyu zaten tanıyoruz, kurulum gerekmiyor",
"known_server_title": "{host} üzerinde başka bir hesap",
"link_confirm_connect": "Bir bağlantı bu sayfada giriş yapmanızı istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.",
"link_confirm_connect_add": "Bir bağlantı bu sayfada giriş yaparak hesap eklemenizi istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.",
"link_confirm_connect_title": "{host} giriş sayfası açılsın mı?",
"link_confirm_pair": "Bu bağlantı {host} üzerindeki bir hesapta oturum açar. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.",
"link_confirm_pair_add": "Bu bağlantı {host} üzerindeki bir hesabı ekler ve uygulamayı bu hesaba geçirir. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.",
"link_confirm_title": "Bağlantıyla giriş yapılsın mı?",
"looking_up": "Sunucunuz aranıyor…",
"manual": "Sunucu bilgilerini elle girin",
"notice_bad_qr": "Bu bir Bulwark giriş kodu değil",
@@ -505,6 +529,12 @@
"pairing_detail": "Bu cihaz {host} ile eşleştiriliyor…",
"password_subtitle": "Parolanız yalnızca bu sunucuya gönderilir ve cihazın anahtar deposunda saklanır.",
"password_title": "Giriş yap: {host}",
"paste_button": "Yapıştır",
"paste_clipboard_empty": "Yapıştırılacak bir şey yok. Önce bağlantıyı webmail'de kopyalayın.",
"paste_hint": "Webmail'de Ayarlar → Güvenlik → Mobil uygulamayı bağla bölümünü açın, giriş bağlantısını kopyalayıp buraya yapıştırın.",
"paste_invalid": "Bu bir Bulwark giriş bağlantısı değil",
"paste_label": "Giriş bağlantısı",
"paste_title": "Giriş bağlantısını yapıştır",
"provider_detail": "Parolanızı sağlayıcının sayfasında yazacaksınız. Bulwark yalnızca istediğiniz zaman iptal edebileceğiniz bir giriş belirteci saklar.",
"provider_title": "Parolanız sağlayıcınızda kalır",
"qr_hint": "Ayarlar → Güvenlik → Mobil uygulamayı bağla bir kod gösterir. Kamerayı ona doğrultun.",
@@ -515,6 +545,7 @@
"server_hint": "Tarayıcıya yazdığınız adresin aynısı. https:// kısmını biz ekleriz.",
"server_label": "Sunucu adresi",
"server_not_found": "{domain} için bir sunucu bulamadık. Kendi sunucunuzu barındırıyorsanız webmail için kullandığınız adresi girin.",
"server_paste_hint": "Bunun yerine bir giriş bağlantınız mı var? Buraya yapıştırın.",
"server_scan_hint": "Emin değilseniz bunun yerine bir giriş kodu tarayın. Kod, sunucu adresini içerir.",
"server_subtitle": "Webmail'inize ulaşmak için kullandığınız adresi girin.",
"server_title": "Sunucu adresiniz nedir?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "Яка ваша адреса електронної пошти?",
"email_step_title_add": "Яка ваша інша адреса?",
"email_title": "Використати мою адресу електронної пошти",
"err_account_limit_detail": "Видаліть обліковий запис у налаштуваннях, щоб додати новий.",
"err_bad_detail": "Перевірте адресу електронної пошти та пароль. Якщо у вашому обліковому записі ввімкнено двофакторну автентифікацію, створіть пароль програми у веб-пошті й використайте його тут.",
"err_bad_title": "Не вдалося",
"err_cert_detail": "Сертифікат безпеки сервера відхилено. Якщо це ваш власний сервер, перевірте, що сертифікат дійсний і не прострочений.",
"err_cert_title": "Не вдалося перевірити {host}",
"err_code_expired_detail": "Коди входу діють кілька хвилин. Створіть новий у веб-пошті та відскануйте його знову.",
"err_code_expired_detail": "Коди входу діють дві хвилини. Покажіть новий у веб-пошті та відскануйте його знову.",
"err_code_expired_or_used_detail": "Покажіть новий у веб-пошті та відскануйте його знову.",
"err_code_expired_or_used_title": "Термін дії коду минув, або його вже використано",
"err_code_expired_title": "Термін дії коду минув",
"err_code_invalid_detail": "Покажіть новий код у веб-пошті (Налаштування → Безпека → Прив'язати мобільний застосунок) і відскануйте його.",
"err_code_invalid_title": "Цей код недійсний",
"err_code_used_detail": "Кожен код працює лише один раз. Покажіть новий у веб-пошті та відскануйте його знову.",
"err_code_used_title": "Цей код уже використано",
"err_generic_detail": "Щось пішло не так. Спробуйте ще раз.",
"err_generic_title": "Не вдалося ввійти",
"err_interrupted_detail": "Відповідь не збігається з розпочатим запитом. Спробуйте ввійти ще раз.",
@@ -474,6 +481,17 @@
"err_no_server_title": "Поштовий сервер не знайдено: {host}",
"err_no_webmail_detail": "На цьому сервері немає веб-пошти Bulwark і входу через OAuth. Використайте пароль (або пароль програми).",
"err_no_webmail_title": "Немає сторінки входу: {host}",
"err_pair_connect_detail": "Покажіть новий код на комп'ютері та відскануйте його знову.",
"err_pair_connect_title": "Вхід виконано, але не вдалося під'єднатися до {host}",
"err_pair_insecure_detail": "{host} хоче використати незашифроване з'єднання.",
"err_pair_network_detail": "Перевірте підключення та чи доступна веб-пошта з телефона, потім спробуйте ще раз.",
"err_pair_rate_detail": "Зачекайте хвилину, потім спробуйте ще раз.",
"err_pair_server_detail": "Спробуйте ще раз із новим кодом. Якщо помилка повторюється, зверніться до адміністратора.",
"err_pair_server_title": "Веб-пошті не вдалося завершити вхід",
"err_pair_unsupported_detail": "{host} не підтримує коди входу. Можливо, потрібне оновлення, або посилання веде на неправильну адресу.",
"err_pair_unsupported_title": "Ця веб-пошта не може прив'язувати пристрої",
"err_pair_untrusted_detail": "{host} надіслав дані для входу на сервер, який застосунок не може перевірити.",
"err_pair_untrusted_title": "Цьому входу не можна довіряти",
"err_rate_detail": "Сервер попросив трохи зачекати. Спробуйте ще раз згодом.",
"err_rate_detail_seconds": "Сервер попросив зачекати. Спробуйте ще раз приблизно через {seconds} с.",
"err_rate_title": "Забагато спроб",
@@ -487,6 +505,12 @@
"know_server": "Я знаю адресу свого сервера",
"known_server_desc": "Цей сервер уже відомий, налаштування не потрібне",
"known_server_title": "Ще один обліковий запис на {host}",
"link_confirm_connect": "Посилання пропонує увійти на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.",
"link_confirm_connect_add": "Посилання пропонує додати обліковий запис, увійшовши на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.",
"link_confirm_connect_title": "Відкрити сторінку входу {host}?",
"link_confirm_pair": "Це посилання виконує вхід в обліковий запис на {host}. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.",
"link_confirm_pair_add": "Це посилання додає обліковий запис з {host} і перемикає на нього застосунок. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.",
"link_confirm_title": "Увійти за посиланням?",
"looking_up": "Шукаємо ваш сервер…",
"manual": "Ввести дані сервера вручну",
"notice_bad_qr": "Це не код входу Bulwark",
@@ -505,6 +529,12 @@
"pairing_detail": "Прив'язка пристрою: {host}…",
"password_subtitle": "Пароль надсилається лише на цей сервер і зберігається в захищеному сховищі пристрою.",
"password_title": "Вхід на {host}",
"paste_button": "Вставити",
"paste_clipboard_empty": "Нічого вставляти. Спочатку скопіюйте посилання у веб-пошті.",
"paste_hint": "У веб-пошті відкрийте Налаштування → Безпека → Прив'язати мобільний застосунок, скопіюйте посилання для входу та вставте його сюди.",
"paste_invalid": "Це не посилання для входу Bulwark",
"paste_label": "Посилання для входу",
"paste_title": "Вставити посилання для входу",
"provider_detail": "Ви введете його на їхній сторінці. Bulwark зберігає лише токен входу, який можна будь-коли відкликати.",
"provider_title": "Ваш пароль залишається у провайдера",
"qr_hint": "У розділі Налаштування → Безпека → Прив'язати мобільний застосунок з'явиться код. Наведіть на нього камеру.",
@@ -515,6 +545,7 @@
"server_hint": "Та сама адреса, яку ви вводите в браузері. Ми самі додамо https://.",
"server_label": "Адреса сервера",
"server_not_found": "Не вдалося знайти сервер для {domain}. Якщо у вас власний сервер, вкажіть адресу, за якою ви відкриваєте веб-пошту.",
"server_paste_hint": "Маєте посилання для входу? Вставте його сюди.",
"server_scan_hint": "Якщо не впевнені, відскануйте код входу — він містить адресу сервера.",
"server_subtitle": "Введіть адресу, за якою ви відкриваєте веб-пошту.",
"server_title": "Яка адреса вашого сервера?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "您的電子郵件地址是什麼?",
"email_step_title_add": "另一個電子郵件地址是什麼?",
"email_title": "使用我的電子郵件地址",
"err_account_limit_detail": "請先在設定中移除一個帳號,再新增其他帳號。",
"err_bad_detail": "請檢查您的電子郵件地址與密碼。如果您的帳號使用雙因素驗證登入,請在網頁郵件中建立應用程式密碼,並在此使用。",
"err_bad_title": "無法登入",
"err_cert_detail": "伺服器的安全性憑證遭到拒絕。如果這是您自己的伺服器,請確認憑證有效且未過期。",
"err_cert_title": "無法驗證 {host}",
"err_code_expired_detail": "登入碼只在幾分鐘內有效。請在網頁郵件中產生新的登入碼,再重新掃描。",
"err_code_expired_detail": "登入碼的有效期限為兩分鐘。請在網頁郵件中顯示新的登入碼,再重新掃描。",
"err_code_expired_or_used_detail": "請在網頁郵件中顯示新的登入碼,再重新掃描。",
"err_code_expired_or_used_title": "此登入碼已過期或已被使用",
"err_code_expired_title": "此登入碼已過期",
"err_code_invalid_detail": "請在網頁郵件中顯示新的登入碼(「設定 → 安全 → 連結行動應用程式」),再進行掃描。",
"err_code_invalid_title": "此登入碼無效",
"err_code_used_detail": "每個登入碼只能使用一次。請在網頁郵件中顯示新的登入碼,再重新掃描。",
"err_code_used_title": "此登入碼已被使用",
"err_generic_detail": "發生錯誤。請再試一次。",
"err_generic_title": "登入失敗",
"err_interrupted_detail": "回應與我們發出的請求不符。請重新登入。",
@@ -474,6 +481,17 @@
"err_no_server_title": "{host} 上沒有郵件伺服器",
"err_no_webmail_detail": "此伺服器沒有 Bulwark 網頁郵件,也不支援 OAuth 登入。請改用密碼(或應用程式密碼)。",
"err_no_webmail_title": "{host} 上沒有登入頁面",
"err_pair_connect_detail": "請在電腦上顯示新的登入碼,再重新掃描。",
"err_pair_connect_title": "已登入,但無法連線至 {host}",
"err_pair_insecure_detail": "{host} 想要使用未加密的連線。",
"err_pair_network_detail": "請檢查網路連線,並確認手機可以連上網頁郵件,然後再試一次。",
"err_pair_rate_detail": "請等候一分鐘後再試一次。",
"err_pair_server_detail": "請使用新的登入碼再試一次。如果仍然失敗,請洽詢管理員。",
"err_pair_server_title": "網頁郵件無法完成登入",
"err_pair_unsupported_detail": "{host} 不支援登入碼。可能需要更新,或連結指向了錯誤的地址。",
"err_pair_unsupported_title": "此網頁郵件無法連結裝置",
"err_pair_untrusted_detail": "{host} 傳送了應用程式無法驗證的伺服器登入資訊。",
"err_pair_untrusted_title": "無法信任此次登入",
"err_rate_detail": "伺服器要求我們稍候。請稍後再試。",
"err_rate_detail_seconds": "伺服器要求我們等待。請在約 {seconds} 秒後再試。",
"err_rate_title": "嘗試次數過多",
@@ -487,6 +505,12 @@
"know_server": "我知道伺服器地址",
"known_server_desc": "我們已知道此伺服器,不需要設定",
"known_server_title": "{host} 上的其他帳號",
"link_confirm_connect": "有連結要求在此頁面登入。只有在您的郵件使用 {host} 時才繼續。",
"link_confirm_connect_add": "有連結要求透過在此頁面登入來新增帳號。只有在您的郵件使用 {host} 時才繼續。",
"link_confirm_connect_title": "要開啟 {host} 的登入頁面嗎?",
"link_confirm_pair": "此連結會登入 {host} 上的帳號。只有在您剛在電腦上顯示登入碼時才繼續。",
"link_confirm_pair_add": "此連結會新增 {host} 上的帳號,並將應用程式切換到該帳號。只有在您剛在電腦上顯示登入碼時才繼續。",
"link_confirm_title": "要使用連結登入嗎?",
"looking_up": "正在尋找您的伺服器…",
"manual": "手動輸入伺服器資訊",
"notice_bad_qr": "此代碼不是 Bulwark 登入碼",
@@ -505,6 +529,12 @@
"pairing_detail": "正在將此裝置與 {host} 配對…",
"password_subtitle": "您的密碼只會傳送到此伺服器,並儲存在裝置的鑰匙圈中。",
"password_title": "登入 {host}",
"paste_button": "貼上",
"paste_clipboard_empty": "沒有可貼上的內容。請先在網頁郵件中複製連結。",
"paste_hint": "在網頁郵件中開啟「設定 → 安全 → 連結行動應用程式」,複製登入連結並貼到這裡。",
"paste_invalid": "這不是 Bulwark 登入連結",
"paste_label": "登入連結",
"paste_title": "貼上登入連結",
"provider_detail": "您會在服務供應商的頁面上輸入密碼。Bulwark 只會儲存登入權杖,您可以隨時撤銷。",
"provider_title": "您的密碼只會留在服務供應商端",
"qr_hint": "「設定 → 安全 → 連結行動應用程式」會顯示代碼。請將相機對準代碼。",
@@ -515,6 +545,7 @@
"server_hint": "與您在瀏覽器中輸入的地址相同。我們會自動加上 https://。",
"server_label": "伺服器地址",
"server_not_found": "找不到 {domain} 的伺服器。如果您自行架設,請使用開啟網頁郵件時的地址。",
"server_paste_hint": "有登入連結嗎?請貼到這裡。",
"server_scan_hint": "如果不確定,請改為掃描登入碼。登入碼中包含伺服器地址。",
"server_subtitle": "請輸入您開啟網頁郵件時使用的地址。",
"server_title": "您的伺服器地址是什麼?",
+32 -1
View File
@@ -460,12 +460,19 @@
"email_step_title": "您的邮箱地址是什么?",
"email_step_title_add": "另一个邮箱地址是什么?",
"email_title": "使用我的邮箱地址",
"err_account_limit_detail": "请在设置中移除一个账户后再添加新账户。",
"err_bad_detail": "请检查您的邮箱地址和密码。如果您的账户使用双因素身份验证登录,请在网页邮箱中创建应用密码并在此使用。",
"err_bad_title": "无法登录",
"err_cert_detail": "服务器的安全证书被拒绝。如果这是您自己的服务器,请检查证书是否有效且未过期。",
"err_cert_title": "无法验证 {host}",
"err_code_expired_detail": "登录码仅在几分钟内有效。请在网页邮箱中生成新的登录码,然后重新扫描。",
"err_code_expired_detail": "登录码的有效期为两分钟。请在网页邮箱中显示新的登录码,然后重新扫描。",
"err_code_expired_or_used_detail": "请在网页邮箱中显示新的登录码,然后重新扫描。",
"err_code_expired_or_used_title": "该登录码已过期或已被使用",
"err_code_expired_title": "该登录码已过期",
"err_code_invalid_detail": "请在网页邮箱中显示新的登录码(“设置 → 安全 → 关联移动应用”),然后扫描。",
"err_code_invalid_title": "该登录码无效",
"err_code_used_detail": "每个登录码只能使用一次。请在网页邮箱中显示新的登录码,然后重新扫描。",
"err_code_used_title": "该登录码已被使用",
"err_generic_detail": "出现问题。请重试。",
"err_generic_title": "登录失败",
"err_interrupted_detail": "响应与我们发起的请求不匹配。请重新登录。",
@@ -474,6 +481,17 @@
"err_no_server_title": "{host} 上没有邮件服务器",
"err_no_webmail_detail": "此服务器没有 Bulwark 网页邮箱,也不支持 OAuth 登录。请改用密码(或应用密码)。",
"err_no_webmail_title": "{host} 上没有登录页面",
"err_pair_connect_detail": "请在电脑上显示新的登录码,然后重新扫描。",
"err_pair_connect_title": "已登录,但无法连接到 {host}",
"err_pair_insecure_detail": "{host} 想要使用未加密的连接。",
"err_pair_network_detail": "请检查网络连接,并确认手机可以访问网页邮箱,然后重试。",
"err_pair_rate_detail": "请等待一分钟后重试。",
"err_pair_server_detail": "请使用新的登录码重试。如果仍然失败,请联系管理员。",
"err_pair_server_title": "网页邮箱无法完成登录",
"err_pair_unsupported_detail": "{host} 不支持登录码。它可能需要更新,或者链接指向了错误的地址。",
"err_pair_unsupported_title": "该网页邮箱无法关联设备",
"err_pair_untrusted_detail": "{host} 发送了应用无法验证的服务器的登录信息。",
"err_pair_untrusted_title": "无法信任此次登录",
"err_rate_detail": "服务器要求我们稍候。请稍后重试。",
"err_rate_detail_seconds": "服务器要求我们等待。请在约 {seconds} 秒后重试。",
"err_rate_title": "尝试次数过多",
@@ -487,6 +505,12 @@
"know_server": "我知道服务器地址",
"known_server_desc": "我们已经知道此服务器,无需设置",
"known_server_title": "{host} 上的其他账户",
"link_confirm_connect": "有链接请求在此页面登录。仅当您的邮箱使用 {host} 时才继续。",
"link_confirm_connect_add": "有链接请求通过在此页面登录来添加账户。仅当您的邮箱使用 {host} 时才继续。",
"link_confirm_connect_title": "打开 {host} 的登录页面?",
"link_confirm_pair": "此链接将登录 {host} 上的账户。仅当您刚刚在电脑上显示了登录码时才继续。",
"link_confirm_pair_add": "此链接将添加 {host} 上的账户,并将应用切换到该账户。仅当您刚刚在电脑上显示了登录码时才继续。",
"link_confirm_title": "使用链接登录?",
"looking_up": "正在查找您的服务器…",
"manual": "手动输入服务器信息",
"notice_bad_qr": "该代码不是 Bulwark 登录码",
@@ -505,6 +529,12 @@
"pairing_detail": "正在将此设备与 {host} 配对…",
"password_subtitle": "您的密码只会发送到此服务器,并存储在设备的钥匙串中。",
"password_title": "登录 {host}",
"paste_button": "粘贴",
"paste_clipboard_empty": "没有可粘贴的内容。请先在网页邮箱中复制链接。",
"paste_hint": "在网页邮箱中打开“设置 → 安全 → 关联移动应用”,复制登录链接并粘贴到这里。",
"paste_invalid": "这不是 Bulwark 登录链接",
"paste_label": "登录链接",
"paste_title": "粘贴登录链接",
"provider_detail": "您将在服务提供商的页面上输入密码。Bulwark 只存储登录令牌,您可以随时撤销。",
"provider_title": "您的密码只保留在服务提供商处",
"qr_hint": "“设置 → 安全 → 关联移动应用”中会显示一个二维码。请将相机对准它。",
@@ -515,6 +545,7 @@
"server_hint": "即您在浏览器中输入的地址。我们会自动为您添加 https://。",
"server_label": "服务器地址",
"server_not_found": "找不到 {domain} 的服务器。如果您自行托管,请使用访问网页邮箱时的地址。",
"server_paste_hint": "有登录链接?请粘贴到这里。",
"server_scan_hint": "如果不确定,请改为扫描登录码。登录码中包含服务器地址。",
"server_subtitle": "请输入您访问网页邮箱时使用的地址。",
"server_title": "您的服务器地址是什么?",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.65",
"version": "0.1.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.65",
"version": "0.1.67",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.65",
"version": "0.1.67",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+183
View File
@@ -0,0 +1,183 @@
import React from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Modal,
Platform,
KeyboardAvoidingView,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { ClipboardPaste, X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
interface PasteSignInLinkModalProps {
visible: boolean;
onClose: () => void;
// Fires with a link that parsed; the parent closes the modal and signs in
// with it exactly as with a scanned code.
onSubmit: (payload: QrLoginPayload) => void;
}
/**
* For when the code can't be scanned (a phone showing the webmail itself, a
* broken camera): the same `bulwarkmail://` link, pasted as text.
*/
export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignInLinkModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((st) => st.t);
const [text, setText] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [focused, setFocused] = React.useState(false);
React.useEffect(() => {
if (!visible) return;
setText('');
setError(null);
}, [visible]);
const paste = React.useCallback(async () => {
let value = '';
try {
value = await Clipboard.getStringAsync();
} catch {
value = '';
}
if (!value.trim()) {
setError(t('login.mobile.paste_clipboard_empty', 'Nothing to paste. Copy the link in the webmail first.'));
return;
}
setText(value.trim());
setError(null);
}, [t]);
const submit = React.useCallback(() => {
const payload = parsePastedSignInLink(text);
if (!payload) {
// A pairing link for a plain-http webmail: say why it won't be used.
const insecure = insecurePairingLinkError(text);
if (insecure) {
const copy = describeLoginError(insecure, { t });
setError(copy.detail ?? copy.title);
return;
}
setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link"));
return;
}
onSubmit(payload);
}, [onSubmit, t, text]);
const title = t('login.mobile.paste_title', 'Paste sign-in link');
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
<Pressable
onPress={onClose}
hitSlop={10}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={24} color={c.text} />
</Pressable>
<Text style={styles.title} accessibilityRole="header">{title}</Text>
<View style={styles.closeButton} />
</View>
<ScrollView
style={styles.flex}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.hint}>
{t('login.mobile.paste_hint', 'In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.')}
</Text>
<TextInput
value={text}
onChangeText={(value) => {
setText(value);
setError(null);
}}
placeholder="bulwarkmail://pair?server=…&code=…"
placeholderTextColor={c.textMuted}
accessibilityLabel={t('login.mobile.paste_label', 'Sign-in link')}
multiline
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
textAlignVertical="top"
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={[styles.input, focused && styles.inputFocused, error ? styles.inputError : null]}
/>
{error ? (
<Text style={styles.error} accessibilityLiveRegion="polite">{error}</Text>
) : null}
<Button
variant="outline"
size="md"
onPress={() => void paste()}
icon={<ClipboardPaste size={16} color={c.text} />}
>
{t('login.mobile.paste_button', 'Paste')}
</Button>
<Button variant="default" size="md" onPress={submit} disabled={!text.trim()}>
{t('login.sign_in', 'Sign in')}
</Button>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
flex: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: c.text },
content: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xxxl, gap: spacing.lg },
hint: { ...typography.body, color: c.textSecondary },
input: {
...typography.body,
color: c.text,
minHeight: 96,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
},
inputFocused: { borderColor: c.borderFocus, borderWidth: 2 },
inputError: { borderColor: c.error },
error: { ...typography.caption, color: c.error, marginTop: -spacing.sm },
});
}
@@ -41,6 +41,8 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'components/TemplateSheet.tsx': [],
'components/IdentitySheet.tsx': [],
'components/QrScanModal.tsx': [],
// The input's placeholder is the link's own format.
'components/PasteSignInLinkModal.tsx': ['bulwarkmail://pair?server=…&code=…'],
'components/email/ActionSheet.tsx': [],
'components/email/AddressActionSheet.tsx': [],
'components/email/CalendarInvitationBanner.tsx': [],
+96 -3
View File
@@ -1,5 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describeLoginError } from '../login-errors';
import { PairingError, insecurePairingLinkError, type PairingErrorReason } from '../oauth';
import { AccountLimitError } from '../account-utils';
function named(name: string, message: string): Error {
const err = new Error(message);
@@ -19,6 +21,24 @@ describe('describeLoginError', () => {
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
});
it('says the account limit is reached and what to do about it', () => {
const want = {
title: 'Maximum of 10 accounts reached.',
detail: 'Remove an account in Settings to add another one.',
};
expect(describeLoginError(new AccountLimitError())).toEqual(want);
// As the store keeps it: the message alone.
expect(describeLoginError('Maximum of 10 accounts reached')).toEqual(want);
});
it('explains a scanned, pasted or tapped pairing link for a plain-http webmail', () => {
const link = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org/mail')}&code=${'a1'.repeat(32)}`;
expect(describeLoginError(insecurePairingLinkError(link))).toEqual({
title: "This sign-in can't be trusted",
detail: 'webmail.example.org wants to use an unencrypted connection.',
});
});
it('names the host it could not reach', () => {
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
serverUrl: 'https://mail.example.com',
@@ -45,10 +65,10 @@ describe('describeLoginError', () => {
expect(copy.title).toBe("Couldn't verify mail.example.com");
});
it('explains an expired pairing code', () => {
it('explains a pairing failure the store kept only as a message', () => {
const copy = describeLoginError(new Error('Pairing code is invalid or has expired'));
expect(copy.title).toBe('That code has expired');
expect(copy.detail).toMatch(/fresh one/i);
expect(copy.title).toBe('That code has expired or was already used');
expect(copy.detail).toBe('Show a new one in the webmail and scan again.');
});
it('passes through an unrecognised message rather than inventing a cause', () => {
@@ -62,3 +82,76 @@ describe('describeLoginError', () => {
expect(describeLoginError('boom').detail).toBe('boom');
});
});
describe('describeLoginError for sign-in codes', () => {
const webmail = { serverUrl: 'https://webmail.example.org/mail' };
const pairing = (reason: PairingErrorReason, host?: string) =>
describeLoginError(new PairingError(reason, 'raw message', host ? { host } : undefined), webmail);
it('says what happened to the code', () => {
expect(pairing('expired')).toEqual({
title: 'That code has expired',
detail: 'Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.',
});
expect(pairing('used')).toEqual({
title: 'That code was already used',
detail: 'Each code works once. Show a new one in the webmail and scan again.',
});
expect(pairing('expired_or_used')).toEqual({
title: 'That code has expired or was already used',
detail: 'Show a new one in the webmail and scan again.',
});
expect(pairing('invalid')).toEqual({
title: "That code isn't valid",
detail: 'Show a new code in the webmail (Settings → Security → Link Mobile App) and scan it.',
});
});
it('names the webmail for problems with the redeem request', () => {
expect(pairing('unsupported', 'webmail.example.org')).toEqual({
title: "This webmail can't link devices",
detail: "webmail.example.org doesn't support sign-in codes. It may need an update, or the link points to the wrong address.",
});
expect(pairing('network', 'webmail.example.org')).toEqual({
title: "Can't reach webmail.example.org",
detail: 'Check your connection and that your phone can reach the webmail, then try again.',
});
// Without a host on the error, the screen's address is used.
expect(pairing('network').title).toBe("Can't reach webmail.example.org");
expect(pairing('untrusted', 'webmail.example.org')).toEqual({
title: "This sign-in can't be trusted",
detail: "webmail.example.org sent sign-in details for a server the app can't verify.",
});
expect(pairing('insecure', 'webmail.example.org')).toEqual({
title: "This sign-in can't be trusted",
detail: 'webmail.example.org wants to use an unencrypted connection.',
});
});
it('explains rate limiting and server trouble', () => {
expect(pairing('rate_limited')).toEqual({ title: 'Too many attempts', detail: 'Wait a minute, then try again.' });
const server = {
title: "The webmail couldn't complete the sign-in",
detail: 'Try again with a new code. If it keeps failing, ask your administrator.',
};
expect(pairing('server')).toEqual(server);
expect(pairing('bad_response')).toEqual(server);
});
it('names the mail server when the code worked but connecting did not', () => {
expect(pairing('connect_failed', 'mail.example.com')).toEqual({
title: "Signed in, but couldn't connect to mail.example.com",
detail: 'Show a new code on your computer and scan again.',
});
});
it('translates through t with the host filled in', () => {
const t = (key: string, _fallback?: string, params?: Record<string, string | number>) =>
`${key}${params?.host ? `(${params.host})` : ''}`;
expect(describeLoginError(new PairingError('connect_failed', 'x', { host: 'mail.example.com' }), { t })).toEqual({
title: 'login.mobile.err_pair_connect_title(mail.example.com)',
detail: 'login.mobile.err_pair_connect_detail',
});
expect(describeLoginError(new PairingError('expired'), { t }).title).toBe('login.mobile.err_code_expired_title');
});
});
+357
View File
@@ -0,0 +1,357 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { mockSecureFetch } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
}));
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
}));
import {
PAIRING_REDEEM_TIMEOUT_MS,
PairingError,
TransientRefreshError,
insecurePairingLinkError,
isInsecurePairingUrl,
parsePastedSignInLink,
parseQrLoginPayload,
redeemPairingCode,
refreshOAuthAccessToken,
signInLinkHost,
} from '../oauth';
const CODE = 'a1'.repeat(32);
const WEBMAIL = 'https://webmail.example.org/mail';
const SERVER = 'https://mail.example.com';
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function html(status: number): Response {
return new Response('<!doctype html><html><body>Not here</body></html>', {
status,
headers: { 'Content-Type': 'text/html' },
});
}
async function redeemFailure(response: Response | Error): Promise<PairingError> {
if (response instanceof Error) mockSecureFetch.mockRejectedValueOnce(response);
else mockSecureFetch.mockResolvedValueOnce(response);
try {
await redeemPairingCode(WEBMAIL, CODE);
} catch (err) {
expect(err).toBeInstanceOf(PairingError);
return err as PairingError;
}
throw new Error('redeemPairingCode resolved');
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('parseQrLoginPayload', () => {
const server = encodeURIComponent('https://mail.example.com/webmail');
it('reads a pairing link, with or without a slash after the host', () => {
const expected = { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code: CODE };
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${server}&code=${CODE}`)).toEqual(expected);
expect(parseQrLoginPayload(`bulwarkmail://pair/?server=${server}&code=${CODE}`)).toEqual(expected);
expect(parseQrLoginPayload(` BulwarkMail://PAIR?code=${CODE}&server=${server}\n`)).toEqual(expected);
});
it('reads a server-bootstrap link and a bare webmail address', () => {
expect(parseQrLoginPayload('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com'))
.toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
expect(parseQrLoginPayload('bulwarkmail://connect/?server=https://mail.example.com'))
.toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
// Unchanged: a connect link may still name a plain-http server.
expect(parseQrLoginPayload('bulwarkmail://connect?server=http://mail.example.com'))
.toEqual({ kind: 'connect', webmailUrl: 'http://mail.example.com' });
expect(parseQrLoginPayload('https://mail.example.com')).toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
});
it('only redeems a pairing code over https, or plain http on loopback', () => {
expect(parseQrLoginPayload(`bulwarkmail://pair?server=http://mail.example.com&code=${CODE}`)).toBeNull();
expect(parseQrLoginPayload(`bulwarkmail://pair?server=http%3A%2F%2F192.168.1.5%3A3000&code=${CODE}`)).toBeNull();
for (const dev of ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://10.0.2.2:3000']) {
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${encodeURIComponent(dev)}&code=${CODE}`))
.toEqual({ kind: 'pair', webmailUrl: dev, code: CODE });
}
});
it('rejects links without a server or a usable code, and garbage', () => {
expect(parseQrLoginPayload(`bulwarkmail://pair?code=${CODE}`)).toBeNull();
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${server}`)).toBeNull();
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${server}&code=has%20space`)).toBeNull();
expect(parseQrLoginPayload(`bulwarkmail://pair?server=javascript:alert(1)&code=${CODE}`)).toBeNull();
expect(parseQrLoginPayload(`bulwarkmail://unlock?server=${server}&code=${CODE}`)).toBeNull();
expect(parseQrLoginPayload(`bulwarkmail://pairing?server=${server}&code=${CODE}`)).toBeNull();
expect(parseQrLoginPayload('WIFI:S:home;T:WPA;P:secret;;')).toBeNull();
expect(parseQrLoginPayload('')).toBeNull();
});
});
describe('parsePastedSignInLink', () => {
it('finds the link inside pasted text', () => {
const link = `bulwarkmail://pair?server=${encodeURIComponent(WEBMAIL)}&code=${CODE}`;
expect(parsePastedSignInLink(`Open this on your phone: ${link}.`))
.toEqual({ kind: 'pair', webmailUrl: WEBMAIL, code: CODE });
expect(parsePastedSignInLink(`\n ${link} \n`)).toEqual({ kind: 'pair', webmailUrl: WEBMAIL, code: CODE });
});
it('accepts a pasted webmail address and rejects anything else', () => {
expect(parsePastedSignInLink('https://mail.example.com')).toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
expect(parsePastedSignInLink('my code is 123456')).toBeNull();
expect(parsePastedSignInLink('')).toBeNull();
});
});
describe('insecurePairingLinkError', () => {
const httpLink = `bulwarkmail://pair?server=${encodeURIComponent('http://Mail.Example.com:8080/webmail')}&code=${CODE}`;
it('says why a pairing link for a plain-http webmail is refused', () => {
// Still refused by the parser: nothing can redeem it.
expect(parseQrLoginPayload(httpLink)).toBeNull();
expect(parsePastedSignInLink(`Open ${httpLink} on your phone.`)).toBeNull();
for (const text of [httpLink, `Open ${httpLink}.`, `bulwarkmail://pair/?code=${CODE}&server=http://mail.example.com:8080`]) {
const err = insecurePairingLinkError(text);
expect(err).toBeInstanceOf(PairingError);
expect(err).toMatchObject({ reason: 'insecure', host: 'mail.example.com:8080' });
}
});
it('names the host the code would really go to', () => {
const spoof = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org@attacker.example')}&code=${CODE}`;
expect(insecurePairingLinkError(spoof)).toMatchObject({ host: 'attacker.example' });
});
it('is null for links that are fine or broken for another reason', () => {
expect(insecurePairingLinkError(`bulwarkmail://pair?server=${encodeURIComponent(WEBMAIL)}&code=${CODE}`)).toBeNull();
expect(insecurePairingLinkError(`bulwarkmail://pair?server=http%3A%2F%2Flocalhost%3A3000&code=${CODE}`)).toBeNull();
expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com&code=has%20space')).toBeNull();
expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com')).toBeNull();
expect(insecurePairingLinkError('bulwarkmail://connect?server=http://mail.example.com')).toBeNull();
expect(insecurePairingLinkError(`bulwarkmail://pair?server=ftp://mail.example.com&code=${CODE}`)).toBeNull();
expect(insecurePairingLinkError('http://mail.example.com')).toBeNull();
expect(insecurePairingLinkError('')).toBeNull();
});
});
describe('isInsecurePairingUrl', () => {
it('allows https and loopback http only', () => {
expect(isInsecurePairingUrl('https://mail.example.com')).toBe(false);
expect(isInsecurePairingUrl('http://localhost:3000')).toBe(false);
expect(isInsecurePairingUrl('http://10.0.2.2:3000')).toBe(false);
expect(isInsecurePairingUrl('http://mail.example.com')).toBe(true);
expect(isInsecurePairingUrl('http://192.168.1.5:3000')).toBe(true);
});
});
describe('signInLinkHost', () => {
it('names the host the link talks to', () => {
expect(signInLinkHost('https://mail.example.com/webmail')).toBe('mail.example.com');
expect(signInLinkHost('https://Mail.Example.com:8443')).toBe('mail.example.com:8443');
expect(signInLinkHost('http://127.0.0.1:3000/?x=1')).toBe('127.0.0.1:3000');
});
it('is not fooled by user info or backslashes', () => {
expect(signInLinkHost('https://mail.example.com@attacker.example/webmail')).toBe('attacker.example');
expect(signInLinkHost('https://a@mail.example.com@attacker.example')).toBe('attacker.example');
expect(signInLinkHost('https://attacker.example\\@mail.example.com')).toBe('attacker.example');
expect(signInLinkHost('https://attacker.example#@mail.example.com')).toBe('attacker.example');
expect(signInLinkHost('https://attacker.example?@mail.example.com')).toBe('attacker.example');
});
});
describe('redeemPairingCode', () => {
it('posts the code to the webmail and returns an OAuth bundle for its token proxy', async () => {
mockSecureFetch.mockResolvedValueOnce(json(200, {
flow: 'oauth',
server_url: SERVER,
access_token: 'at-1',
refresh_token: 'sealed.opaque.refresh',
expires_in: 3600,
token_endpoint: `${WEBMAIL}/api/auth/pair/token`,
client_id: 'bulwark-webmail',
}));
const before = Date.now();
const result = await redeemPairingCode(`${WEBMAIL}/`, CODE);
expect(result).toEqual({
flow: 'oauth',
serverUrl: SERVER,
tokens: {
accessToken: 'at-1',
refreshToken: 'sealed.opaque.refresh',
expiresAt: expect.any(Number),
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
clientId: 'bulwark-webmail',
source: 'pairing',
},
});
if (result.flow !== 'oauth') throw new Error('unreachable');
expect(result.tokens.expiresAt).toBeGreaterThanOrEqual(before + 3600_000);
const [url, init] = mockSecureFetch.mock.calls[0];
expect(url).toBe(`${WEBMAIL}/api/auth/pair/redeem`);
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ pairing_code: CODE });
expect(Object.keys(init.headers).map((h) => h.toLowerCase())).not.toContain('origin');
expect(Object.keys(init.headers).map((h) => h.toLowerCase())).not.toContain('cookie');
// Deadline for both the fetch path (signal) and the client-cert path.
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(init.timeoutMs).toBe(PAIRING_REDEEM_TIMEOUT_MS);
});
it('accepts the mail server as token endpoint, and a bundle without `flow` from an older webmail', async () => {
mockSecureFetch.mockResolvedValueOnce(json(200, {
server_url: SERVER,
access_token: 'at-2',
token_endpoint: `${SERVER}/auth/token`,
client_id: 'webmail',
}));
const result = await redeemPairingCode(WEBMAIL, CODE);
expect(result).toMatchObject({ flow: 'oauth', serverUrl: SERVER, tokens: { tokenEndpoint: `${SERVER}/auth/token`, source: 'pairing' } });
});
it('returns app-password credentials', async () => {
mockSecureFetch.mockResolvedValueOnce(json(200, {
flow: 'password',
server_url: SERVER,
username: 'ada@example.com',
password: 'app-pass-123',
}));
await expect(redeemPairingCode(WEBMAIL, CODE)).resolves.toEqual({
flow: 'password',
serverUrl: SERVER,
username: 'ada@example.com',
password: 'app-pass-123',
});
});
it('allows a loopback http mail server for development', async () => {
mockSecureFetch.mockResolvedValueOnce(json(200, {
flow: 'password', server_url: 'http://10.0.2.2:8080', username: 'u', password: 'p',
}));
await expect(redeemPairingCode('http://10.0.2.2:3000', CODE)).resolves.toMatchObject({ serverUrl: 'http://10.0.2.2:8080' });
});
it('never sends a code to a plain-http webmail', async () => {
await expect(redeemPairingCode('http://webmail.example.org/mail', CODE))
.rejects.toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'webmail.example.org' });
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('tells an unknown code from an expired or a used one', async () => {
expect((await redeemFailure(json(400, { error: 'invalid_code' }))).reason).toBe('invalid');
expect((await redeemFailure(json(410, { error: 'expired_code' }))).reason).toBe('expired');
expect((await redeemFailure(json(410, { error: 'used_code' }))).reason).toBe('used');
expect((await redeemFailure(json(410, {}))).reason).toBe('expired_or_used');
});
it('reads the catch-all 400 of webmail up to 1.11 as expired or used', async () => {
expect((await redeemFailure(json(400, { error: 'Invalid or expired pairing code' }))).reason).toBe('expired_or_used');
expect((await redeemFailure(json(400, { error: 'Missing pairing code' }))).reason).toBe('expired_or_used');
});
it('says the webmail cannot pair when the route is missing, naming the webmail', async () => {
const err = await redeemFailure(html(404));
expect(err.reason).toBe('unsupported');
expect(err.host).toBe('webmail.example.org');
});
it('maps rate limiting and server errors', async () => {
expect((await redeemFailure(json(429, { error: 'Too many requests' }))).reason).toBe('rate_limited');
expect((await redeemFailure(json(500, { error: 'Internal server error' }))).reason).toBe('server');
expect((await redeemFailure(html(502))).reason).toBe('server');
});
it('refuses a 200 that is not JSON', async () => {
expect((await redeemFailure(html(200))).reason).toBe('bad_response');
});
it('refuses answers with missing fields or an unknown flow', async () => {
expect((await redeemFailure(json(200, { flow: 'oauth', server_url: SERVER, access_token: 'x' }))).reason).toBe('bad_response');
expect((await redeemFailure(json(200, { flow: 'password', server_url: SERVER, username: 'u' }))).reason).toBe('bad_response');
expect((await redeemFailure(json(200, { flow: 'magic', server_url: SERVER }))).reason).toBe('bad_response');
expect((await redeemFailure(json(200, { flow: 'password', username: 'u', password: 'p' }))).reason).toBe('bad_response');
});
it('reports an unreachable webmail as a network problem', async () => {
const err = await redeemFailure(new TypeError('Network request failed'));
expect(err.reason).toBe('network');
expect(err.host).toBe('webmail.example.org');
});
it('gives up after the timeout and aborts the request', async () => {
vi.useFakeTimers();
mockSecureFetch.mockImplementationOnce(() => new Promise(() => undefined));
const pending = redeemPairingCode(WEBMAIL, CODE);
const assertion = expect(pending).rejects.toMatchObject({ name: 'PairingError', reason: 'network' });
await vi.advanceTimersByTimeAsync(PAIRING_REDEEM_TIMEOUT_MS);
await assertion;
expect((mockSecureFetch.mock.calls[0][1].signal as AbortSignal).aborted).toBe(true);
});
it('does not hand a refresh token to a token endpoint on a foreign host', async () => {
const err = await redeemFailure(json(200, {
flow: 'oauth',
server_url: SERVER,
access_token: 'at',
refresh_token: 'rt',
token_endpoint: 'https://attacker.example.net/token',
client_id: 'c',
}));
expect(err.reason).toBe('untrusted');
});
it('refuses an unencrypted mail server', async () => {
const err = await redeemFailure(json(200, {
flow: 'password', server_url: 'http://mail.example.com', username: 'u', password: 'p',
}));
expect(err.reason).toBe('insecure');
});
});
describe('refreshOAuthAccessToken with a non-JSON answer', () => {
it('keeps the account: a 200 page that is not JSON is transient', async () => {
mockSecureFetch.mockResolvedValueOnce(html(200));
await expect(refreshOAuthAccessToken({
accessToken: 'old',
refreshToken: 'non-json-refresh',
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
clientId: 'bulwark-webmail',
source: 'pairing',
})).rejects.toBeInstanceOf(TransientRefreshError);
});
it('sends the standard form-encoded refresh grant to the token proxy', async () => {
mockSecureFetch.mockResolvedValueOnce(json(200, { access_token: 'new', expires_in: 60 }));
const next = await refreshOAuthAccessToken({
accessToken: 'old',
refreshToken: 'sealed.opaque.refresh',
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
clientId: 'bulwark-webmail',
source: 'pairing',
});
expect(next).toMatchObject({ accessToken: 'new', refreshToken: 'sealed.opaque.refresh', source: 'pairing' });
const [url, init] = mockSecureFetch.mock.calls[0];
expect(url).toBe(`${WEBMAIL}/api/auth/pair/token`);
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
grant_type: 'refresh_token',
refresh_token: 'sealed.opaque.refresh',
client_id: 'bulwark-webmail',
});
});
});
+10
View File
@@ -3,6 +3,16 @@
// (MAX_SSE_STREAMS), so nothing scales with this number at runtime.
export const MAX_ACCOUNTS = 10;
/** A new account would take the registry past MAX_ACCOUNTS. */
export class AccountLimitError extends Error {
readonly limit = MAX_ACCOUNTS;
constructor() {
super(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
this.name = 'AccountLimitError';
}
}
export function generateAccountId(username: string, serverUrl: string): string {
let host = serverUrl;
try {
+85 -6
View File
@@ -5,6 +5,7 @@
//
// Errors are matched by `name` and message text rather than `instanceof` so
// this module stays free of the api/ and expo dependency graph.
import { MAX_ACCOUNTS } from './account-utils';
export interface LoginErrorCopy {
title: string;
@@ -34,6 +35,70 @@ function hostLabel(serverUrl: string | null | undefined, fallback: string): stri
return host || fallback;
}
// A QR / link sign-in failure (`PairingError` in lib/oauth), by reason.
function describePairingError(reason: string | undefined, host: string, t: Translate): LoginErrorCopy | null {
switch (reason) {
case 'expired':
return {
title: t('login.mobile.err_code_expired_title', 'That code has expired'),
detail: t('login.mobile.err_code_expired_detail', 'Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.'),
};
case 'used':
return {
title: t('login.mobile.err_code_used_title', 'That code was already used'),
detail: t('login.mobile.err_code_used_detail', 'Each code works once. Show a new one in the webmail and scan again.'),
};
case 'expired_or_used':
return {
title: t('login.mobile.err_code_expired_or_used_title', 'That code has expired or was already used'),
detail: t('login.mobile.err_code_expired_or_used_detail', 'Show a new one in the webmail and scan again.'),
};
case 'invalid':
return {
title: t('login.mobile.err_code_invalid_title', "That code isn't valid"),
detail: t('login.mobile.err_code_invalid_detail', 'Show a new code in the webmail (Settings → Security → Link Mobile App) and scan it.'),
};
case 'unsupported':
return {
title: t('login.mobile.err_pair_unsupported_title', "This webmail can't link devices"),
detail: t('login.mobile.err_pair_unsupported_detail', "{host} doesn't support sign-in codes. It may need an update, or the link points to the wrong address.", { host }),
};
case 'untrusted':
return {
title: t('login.mobile.err_pair_untrusted_title', "This sign-in can't be trusted"),
detail: t('login.mobile.err_pair_untrusted_detail', "{host} sent sign-in details for a server the app can't verify.", { host }),
};
case 'insecure':
return {
title: t('login.mobile.err_pair_untrusted_title', "This sign-in can't be trusted"),
detail: t('login.mobile.err_pair_insecure_detail', '{host} wants to use an unencrypted connection.', { host }),
};
case 'network':
return {
title: t('login.mobile.err_unreachable_title', "Can't reach {host}", { host }),
detail: t('login.mobile.err_pair_network_detail', 'Check your connection and that your phone can reach the webmail, then try again.'),
};
case 'rate_limited':
return {
title: t('login.mobile.err_rate_title', 'Too many attempts'),
detail: t('login.mobile.err_pair_rate_detail', 'Wait a minute, then try again.'),
};
case 'server':
case 'bad_response':
return {
title: t('login.mobile.err_pair_server_title', "The webmail couldn't complete the sign-in"),
detail: t('login.mobile.err_pair_server_detail', 'Try again with a new code. If it keeps failing, ask your administrator.'),
};
case 'connect_failed':
return {
title: t('login.mobile.err_pair_connect_title', "Signed in, but couldn't connect to {host}", { host }),
detail: t('login.mobile.err_pair_connect_detail', 'Show a new code on your computer and scan again.'),
};
default:
return null;
}
}
export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
const name = nameOf(err);
const message = messageOf(err);
@@ -45,6 +110,15 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
});
const host = hostLabel(context.serverUrl, t('login.mobile.the_server', 'the server'));
if (name === 'PairingError') {
const pairing = err as { reason?: string; host?: string };
// The error names the host it is about: the webmail for a failed redeem,
// the mail server once the code was redeemed (`connect_failed`).
const pairingHost = pairing.host || host;
const copy = describePairingError(pairing.reason, pairingHost, t);
if (copy) return copy;
}
if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) {
return {
title: t('login.mobile.err_totp_title', 'Enter your two-factor code'),
@@ -135,11 +209,10 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
};
}
// The store keeps a failed pairing as a plain message; without the reason,
// say both.
if (lower.includes('pairing code')) {
return {
title: t('login.mobile.err_code_expired_title', 'That code has expired'),
detail: t('login.mobile.err_code_expired_detail', 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.'),
};
return describePairingError('expired_or_used', host, t)!;
}
if (lower.includes('state mismatch')) {
@@ -149,8 +222,14 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
};
}
if (lower.includes('maximum of') && lower.includes('accounts')) {
return { title: message };
// No room for another account (AccountLimitError, or its message as the
// store keeps it).
if (name === 'AccountLimitError' || /maximum of \d+ accounts/i.test(message)) {
const count = Number(/maximum of (\d+)/i.exec(message)?.[1]) || MAX_ACCOUNTS;
return {
title: t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count }),
detail: t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
};
}
// Unrecognised: show what we were told rather than inventing a cause.
+265 -52
View File
@@ -19,9 +19,12 @@ export interface OAuthTokens {
expiresAt?: number; // epoch ms
tokenEndpoint: string;
clientId: string;
// Where the bundle came from. A paired phone shares the desktop's refresh
// token, so revoking it on sign-out would also sign the desktop out;
// every other source is safe to revoke.
// Where the bundle came from. A paired phone gets its own grant: the
// webmail mints a separate one for the device rather than handing over
// the desktop's session. Its refresh token may be an opaque string only
// the webmail's token proxy understands, and webmail up to 1.11 did share
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
// of revoking them at the mail server; every other source is revoked.
source?: OAuthTokenSource;
}
@@ -56,6 +59,44 @@ export class TransientRefreshError extends Error {
}
}
// Why a QR / link sign-in failed, so the login screen can say what to do next
// instead of one catch-all "expired" message.
// expired / used / expired_or_used / invalid: the code itself (webmail up to
// 1.11 does not say which, hence `expired_or_used`)
// unsupported: no redeem route at that address (old webmail, wrong link)
// untrusted / insecure: the answer names a token endpoint or server the app
// will not send credentials to
// network / server / rate_limited / bad_response: the redeem request itself
// connect_failed: the code was redeemed, but signing in to the mail server
// with what it returned failed; the code is gone, a new one is needed
export type PairingErrorReason =
| 'expired'
| 'used'
| 'expired_or_used'
| 'invalid'
| 'unsupported'
| 'untrusted'
| 'insecure'
| 'network'
| 'server'
| 'rate_limited'
| 'bad_response'
| 'connect_failed';
export class PairingError extends Error {
readonly reason: PairingErrorReason;
/** Host the message is about: the webmail, or the mail server for `connect_failed`. */
readonly host?: string;
constructor(reason: PairingErrorReason, message?: string, opts?: { host?: string; cause?: unknown }) {
super(message ?? `Pairing failed: ${reason}`);
this.name = 'PairingError';
this.reason = reason;
this.host = opts?.host;
if (opts?.cause !== undefined) this.cause = opts.cause;
}
}
// The state is the only guard against a forged `bulwarkmobile://` redirect
// delivering foreign credentials, so it comes from the platform CSPRNG.
function randomState(): string {
@@ -191,24 +232,35 @@ export async function runWebmailHandoff(
// server URL for the normal webmail handoff (`connect`), or carries a one-time
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
// The payload never contains credentials — the `pair` code is redeemed for
// tokens over the network, once.
// tokens over the network, once. The same `bulwarkmail://` links also arrive
// as deep links (a tapped link) and as pasted text.
export type QrLoginPayload =
| { kind: 'connect'; webmailUrl: string }
| { kind: 'pair'; webmailUrl: string; code: string };
// `bulwarkmail://pair?…` and `bulwarkmail://pair/?…` (Android and some QR
// readers add the slash after the host).
const SIGN_IN_LINK_RE = /^bulwarkmail:\/\/(connect|pair)\/?\?([^#]*)(?:#.*)?$/i;
// The webmail mints 64 hex characters; accept any URL-safe token of sane
// length so a future format does not break older apps.
const PAIRING_CODE_RE = /^[A-Za-z0-9_-]{8,512}$/;
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
const trimmed = raw.trim();
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
const match = SIGN_IN_LINK_RE.exec(trimmed);
if (match) {
const kind = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const server = params.get('server');
if (!server || !/^https?:\/\//i.test(server)) return null;
const server = params.get('server')?.trim();
if (!server || !/^https?:\/\/[^/?#\s]+/i.test(server)) return null;
if (kind === 'pair') {
const code = params.get('code');
if (!code) return null;
// The code is redeemed at this address, and the answer carries
// credentials: only over TLS, or plain http on loopback for development.
if (!isHttpsUrl(server) && !isLoopbackHttp(server)) return null;
const code = params.get('code')?.trim();
if (!code || !PAIRING_CODE_RE.test(code)) return null;
return { kind: 'pair', webmailUrl: server, code };
}
return { kind: 'connect', webmailUrl: server };
@@ -223,59 +275,207 @@ export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
return null;
}
/**
* The code is redeemed at the webmail, and the answer carries credentials:
* only over TLS, or plain http on loopback for development.
*/
export function isInsecurePairingUrl(webmailUrl: string): boolean {
return !isHttpsUrl(webmailUrl) && !isLoopbackHttp(webmailUrl);
}
/**
* The host a sign-in link talks to, for the user to check. Read the way the
* network stack reads the address: `https://mail.example.com@evil.example`
* and `https://evil.example\@mail.example.com` both go to evil.example, so
* that is the host named.
*/
export function signInLinkHost(url: string): string {
const authority = url.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/\\?#]/)[0];
return authority.slice(authority.lastIndexOf('@') + 1).toLowerCase();
}
/**
* Why parseQrLoginPayload refused a pairing link, when the reason is its
* plain-http webmail (the code, and the credentials it buys, would cross the
* network in clear), so a scanned, pasted or tapped link can say so instead
* of "not a Bulwark sign-in code". Only ever an error to show: the parser
* stays the one gate and never yields such a link. Takes the link alone or
* pasted text around it; null for anything else.
*/
export function insecurePairingLinkError(text: string): PairingError | null {
const link = (/bulwarkmail:\/\/\S+/i.exec(text)?.[0] ?? '').replace(/[)\]>.,;'"]+$/, '');
const match = SIGN_IN_LINK_RE.exec(link);
if (!match || match[1].toLowerCase() !== 'pair') return null;
const params = new URLSearchParams(match[2]);
const server = params.get('server')?.trim();
const code = params.get('code')?.trim();
if (!server || !/^http:\/\/[^/?#\s]+/i.test(server) || !isInsecurePairingUrl(server)) return null;
if (!code || !PAIRING_CODE_RE.test(code)) return null;
return new PairingError('insecure', 'Pairing needs an https webmail address', {
host: signInLinkHost(server),
});
}
/**
* Pasted text: the link on its own, or a message that contains it ("Open
* bulwarkmail://pair?… on your phone"). Falls back to the whole text so a
* pasted webmail address still works like a scanned one.
*/
export function parsePastedSignInLink(text: string): QrLoginPayload | null {
const embedded = /bulwarkmail:\/\/\S+/i.exec(text)?.[0];
if (embedded) {
const payload = parseQrLoginPayload(embedded.replace(/[)\]>.,;'"]+$/, ''));
if (payload) return payload;
}
return parseQrLoginPayload(text);
}
export const PAIRING_REDEEM_TIMEOUT_MS = 15_000;
// Host (and port) for messages: "Can't reach mail.example.com".
function displayHost(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/?#]/)[0];
}
// A JSON body, or null for an empty, HTML or otherwise unparseable one. A
// proxy error page or a webmail too old to have the route answers with HTML.
async function readJsonBody(response: Response): Promise<Record<string, unknown> | null> {
try {
const data: unknown = await response.json();
return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record<string, unknown>) : null;
} catch {
return null;
}
}
function stringField(data: Record<string, unknown>, key: string): string | undefined {
const value = data[key];
return typeof value === 'string' && value ? value : undefined;
}
// Map a failed redeem onto what the user should do about it.
function redeemFailure(status: number, body: Record<string, unknown> | null, host: string): PairingError {
const error = body ? stringField(body, 'error') : undefined;
if (status === 410) {
if (error === 'expired_code') return new PairingError('expired', 'The pairing code has expired', { host });
if (error === 'used_code') return new PairingError('used', 'The pairing code was already used', { host });
return new PairingError('expired_or_used', 'The pairing code has expired or was already used', { host });
}
if (status === 400) {
if (error === 'invalid_code') return new PairingError('invalid', 'The pairing code is not valid', { host });
// Webmail up to 1.11 answers every failure with a 400 and
// "Invalid or expired pairing code" / "Missing pairing code".
return new PairingError('expired_or_used', 'The pairing code has expired or was already used', { host });
}
if (status === 404 || status === 405 || (status >= 300 && status < 400)) {
return new PairingError('unsupported', `No pairing endpoint at ${host} (${status})`, { host });
}
if (status === 429) return new PairingError('rate_limited', 'Too many pairing attempts (429)', { host });
return new PairingError('server', `Pairing failed with status ${status}`, { host });
}
// Cross-device pairing redemption. Posts the scanned code to the webmail that
// minted it and maps the token bundle into the same shape the in-browser OAuth
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
// minted it and maps the answer into the same shape the in-browser handoff
// produces: an OAuth token bundle for connectWithOAuth, or an app password
// (the account password on servers without app passwords) for login().
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
const base = webmailUrl.replace(/\/+$/, '');
let response: Response;
const host = displayHost(base);
// The login screen refuses these links already; never send a code in clear.
if (isInsecurePairingUrl(base)) {
throw new PairingError('insecure', 'Pairing needs an https webmail address', { host });
}
// secureFetch's native (client-certificate) path ignores `signal`, so the
// deadline is also passed as `timeoutMs` and enforced here with a race; the
// body read counts against it too.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new PairingError('network', `Timed out reaching ${host}`, { host }));
}, PAIRING_REDEEM_TIMEOUT_MS);
});
let status: number;
let ok: boolean;
let data: Record<string, unknown> | null;
try {
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ pairing_code: code }),
});
} catch {
throw new HandoffError('Could not reach the server to complete pairing');
const exchange = (async () => {
let response: Response;
try {
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ pairing_code: code }),
signal: controller.signal,
timeoutMs: PAIRING_REDEEM_TIMEOUT_MS,
});
} catch (err) {
throw new PairingError(
'network',
`Could not reach ${host}: ${err instanceof Error ? err.message : String(err)}`,
{ host, cause: err },
);
}
return { status: response.status, ok: response.ok, data: await readJsonBody(response) };
})();
({ status, ok, data } = await Promise.race([exchange, deadline]));
} finally {
clearTimeout(timer);
}
if (!response.ok) {
// The code was unknown, already used, or expired (server returns 400) —
// or something else went wrong. Either way the user needs a fresh QR.
throw new HandoffError('Pairing code is invalid or has expired');
if (!ok) throw redeemFailure(status, data, host);
if (!data) throw new PairingError('bad_response', 'The pairing response was not JSON', { host });
const serverUrl = stringField(data, 'server_url');
if (!serverUrl) throw new PairingError('bad_response', 'Pairing response missing server URL', { host });
if (!isHttpsUrl(serverUrl) && !isLoopbackHttp(serverUrl)) {
throw new PairingError('insecure', 'Pairing response server URL must use https', { host });
}
const data = (await response.json()) as {
server_url?: string;
access_token?: string;
refresh_token?: string;
expires_in?: number;
token_endpoint?: string;
client_id?: string;
};
// Webmail up to 1.11 always sends `flow: 'oauth'`; a bundle without the
// field but with a token is the same thing.
const flow = stringField(data, 'flow') ?? (stringField(data, 'access_token') ? 'oauth' : undefined);
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
throw new HandoffError('Pairing response missing token material');
}
if (!isHttpsUrl(data.server_url) && !isLoopbackHttp(data.server_url)) {
throw new HandoffError('Pairing response server URL must use https');
}
if (!isAcceptableTokenEndpoint(data.token_endpoint, data.server_url, base)) {
throw new HandoffError('Pairing response token endpoint is not trusted');
if (flow === 'password') {
const username = stringField(data, 'username');
const password = stringField(data, 'password');
if (!username || !password) {
throw new PairingError('bad_response', 'Pairing response missing credentials', { host });
}
return { flow: 'password', serverUrl, username, password };
}
return {
flow: 'oauth',
serverUrl: data.server_url,
tokens: {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? undefined,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: data.token_endpoint,
clientId: data.client_id,
source: 'pairing',
},
};
if (flow === 'oauth') {
const accessToken = stringField(data, 'access_token');
const tokenEndpoint = stringField(data, 'token_endpoint');
const clientId = stringField(data, 'client_id');
if (!accessToken || !tokenEndpoint || !clientId) {
throw new PairingError('bad_response', 'Pairing response missing token material', { host });
}
// The mail server's own host, or the webmail's token proxy
// (`<webmail>/api/auth/pair/token`); nothing else gets the refresh token.
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, base)) {
throw new PairingError('untrusted', 'Pairing response token endpoint is not trusted', { host });
}
const expiresIn = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) ? data.expires_in : undefined;
return {
flow: 'oauth',
serverUrl,
tokens: {
accessToken,
refreshToken: stringField(data, 'refresh_token'),
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
tokenEndpoint,
clientId,
source: 'pairing',
},
};
}
throw new PairingError('bad_response', `Unknown pairing flow: ${flow ?? 'missing'}`, { host });
}
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
@@ -323,11 +523,24 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
}
throw new TransientRefreshError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
// A 200 that is not JSON is a captive portal or a proxy page, not an
// answer from the token endpoint; the refresh token may still be
// good, so keep the account (a SyntaxError here used to evict it).
let data: {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
try {
data = (await response.json()) as typeof data;
} catch (err) {
throw new TransientRefreshError(
`Token refresh response was not JSON: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!data || typeof data !== 'object') {
throw new TransientRefreshError('Token refresh response was not a JSON object');
}
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
+60 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking';
import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
import { usePendingSignInLinkStore } from '../pending-sign-in-link';
import { usePendingSettingsTab } from '../pending-settings-tab';
import { usePendingCalendarOpen } from '../pending-calendar-open';
import { usePendingMailSearch } from '../pending-mail-search';
@@ -257,3 +258,61 @@ describe('shareToDeepLink', () => {
});
});
});
describe('sign-in links', () => {
const code = 'c0ffee'.repeat(10) + 'beef';
const pair = `bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com/webmail')}&code=${code}`;
it('are never navigation links', () => {
expect(parseDeepLink(pair)).toBeNull();
expect(parseDeepLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBeNull();
// The scheme is not an alias of the app scheme.
expect(parseDeepLink('bulwarkmail://mail/message/M1')).toBeNull();
});
it('parses pair and connect links, and nothing else', () => {
expect(parseSignInLink(pair)).toEqual({ kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code });
expect(parseSignInLink(pair.replace('://pair?', '://pair/?'))).toEqual({
kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code,
});
expect(parseSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com'))
.toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
// A plain webmail address is a QR convenience, not a link that signs in.
expect(parseSignInLink('https://mail.example.com')).toBeNull();
expect(parseSignInLink('bulwarkmobile://mail/message/M1')).toBeNull();
expect(parseSignInLink(`bulwarkmail://pair?server=http%3A%2F%2Fmail.example.com&code=${code}`)).toBeNull();
expect(parseSignInLink(null)).toBeNull();
});
it('parks a sign-in link from the OS as external, for the login screen to take once', () => {
usePendingSignInLinkStore.getState().clear();
expect(acceptSignInLink('bulwarkmobile://mail')).toBe(false);
expect(acceptSignInLink('mailto:a@b.co')).toBe(false);
expect(usePendingSignInLinkStore.getState().pending).toBeNull();
expect(acceptSignInLink(pair)).toBe(true);
expect(usePendingSignInLinkStore.getState().pending).toMatchObject({
source: 'external',
payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code },
});
// Anyone can fire the link, so it is not run until the user confirms.
expect(usePendingSignInLinkStore.getState().take()).toMatchObject({
needsConfirmation: true,
payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code },
});
// Taken means cleared: a second delivery of the same state signs in nothing.
expect(usePendingSignInLinkStore.getState().pending).toBeNull();
expect(usePendingSignInLinkStore.getState().take()).toBeNull();
usePendingSignInLinkStore.getState().clear();
});
it('parks connect links as external too', () => {
usePendingSignInLinkStore.getState().clear();
expect(acceptSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBe(true);
expect(usePendingSignInLinkStore.getState().take()).toMatchObject({
needsConfirmation: true,
payload: { kind: 'connect', webmailUrl: 'https://mail.example.com' },
});
usePendingSignInLinkStore.getState().clear();
});
});
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { acceptSignInLink, routeParkedSignInLink } from '../linking';
import {
setPendingSignInLink,
signInLinkNeedsConfirmation,
usePendingSignInLinkStore,
} from '../pending-sign-in-link';
import type { QrLoginPayload } from '../../lib/oauth';
import { MAX_ACCOUNTS } from '../../lib/account-utils';
// A `bulwarkmail://` link from outside the app (any web page, one tap; any
// installed app, no tap) names a webmail and a code of the sender's choosing.
// It must not be redeemed or opened until the user says Continue; a scanned
// or pasted one is the user's own doing in the app and runs straight away.
const code = 'c0ffee'.repeat(10) + 'beef';
const link = (server: string) =>
`bulwarkmail://pair?server=${encodeURIComponent(server)}&code=${code}`;
const ATTACKER = link('https://attacker.example');
const OTHER = link('https://other.example');
const store = () => usePendingSignInLinkStore.getState();
beforeEach(() => store().clear());
describe('sign-in link confirmation', () => {
it('asks for links from outside the app, not for scanned or pasted ones', () => {
expect(signInLinkNeedsConfirmation('external')).toBe(true);
expect(signInLinkNeedsConfirmation('in-app')).toBe(false);
});
it('holds an external link until the user continues, then runs it once', () => {
acceptSignInLink(ATTACKER);
const taken = store().take();
expect(taken).toMatchObject({ needsConfirmation: true, payload: { kind: 'pair', webmailUrl: 'https://attacker.example', code } });
// Taken for the dialog, not handed out to run.
expect(store().pending).toBeNull();
expect(store().awaitingConfirmation?.id).toBe(taken!.id);
expect(store().take()).toBeNull();
expect(store().confirm(taken!.id)).toEqual({ kind: 'pair', webmailUrl: 'https://attacker.example', code });
expect(store().awaitingConfirmation).toBeNull();
// A second Continue (or a stale dialog) runs nothing.
expect(store().confirm(taken!.id)).toBeNull();
});
it('forgets a cancelled link, so nothing redeems it later', () => {
acceptSignInLink(ATTACKER);
const taken = store().take()!;
expect(store().discard(taken.id)).toBe(true);
expect(store().confirm(taken.id)).toBeNull();
expect(store().take()).toBeNull();
expect(store().pending).toBeNull();
expect(store().awaitingConfirmation).toBeNull();
expect(store().discard(taken.id)).toBe(false);
});
it('drops links delivered while the dialog is open instead of swapping them in', () => {
acceptSignInLink(ATTACKER);
const shown = store().take()!;
// A second 'url' event, or getInitialURL delivering again.
expect(acceptSignInLink(OTHER)).toBe(true);
expect(acceptSignInLink(ATTACKER)).toBe(true);
expect(store().pending).toBeNull();
expect(store().take()).toBeNull();
// Continue runs the link the dialog named, nothing else.
expect(store().confirm(shown.id)).toMatchObject({ webmailUrl: 'https://attacker.example' });
expect(store().take()).toBeNull();
});
it('asks again when a link is delivered again after the dialog closed', () => {
acceptSignInLink(ATTACKER);
const first = store().take()!;
store().discard(first.id);
acceptSignInLink(ATTACKER);
const again = store().take()!;
expect(again.needsConfirmation).toBe(true);
expect(again.id).not.toBe(first.id);
// The cancelled dialog's id cannot confirm the new delivery.
expect(store().confirm(first.id)).toBeNull();
expect(store().awaitingConfirmation?.id).toBe(again.id);
});
it('keeps the newest of two links parked before the login screen takes one', () => {
acceptSignInLink(ATTACKER);
acceptSignInLink(OTHER);
expect(store().take()).toMatchObject({ needsConfirmation: true, payload: { webmailUrl: 'https://other.example' } });
});
it('runs an in-app link without a dialog', () => {
const payload: QrLoginPayload = { kind: 'connect', webmailUrl: 'https://mail.example.com' };
expect(setPendingSignInLink(payload, 'in-app')).toBe(true);
expect(store().take()).toMatchObject({ needsConfirmation: false, payload });
expect(store().awaitingConfirmation).toBeNull();
expect(store().pending).toBeNull();
});
});
describe('refused sign-in links', () => {
it('parks a pairing link for a plain-http webmail as a refusal, with nothing to run', () => {
expect(acceptSignInLink(link('http://mail.example.com'))).toBe(true);
expect(store().pending).toBeNull();
expect(store().take()).toBeNull();
const refusal = store().takeRefusal();
expect(refusal).toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'mail.example.com' });
expect(store().takeRefusal()).toBeNull();
});
it('ignores other broken sign-in links, as before', () => {
expect(acceptSignInLink(`bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com')}`)).toBe(false);
expect(acceptSignInLink('bulwarkmail://unlock?server=https://mail.example.com')).toBe(false);
expect(store().refusal).toBeNull();
expect(store().pending).toBeNull();
});
});
describe('routeParkedSignInLink (signed in)', () => {
it('opens Add account while there is room', () => {
acceptSignInLink(ATTACKER);
expect(routeParkedSignInLink(MAX_ACCOUNTS - 1)).toBe('add-account');
// Still parked for Add account's login screen.
expect(store().pending).not.toBeNull();
});
it('drops the link and reports the limit when the registry is full, so no code is spent', () => {
acceptSignInLink(ATTACKER);
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit');
expect(store().pending).toBeNull();
expect(store().take()).toBeNull();
acceptSignInLink(link('http://mail.example.com'));
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit');
expect(store().refusal).toBeNull();
});
it('does nothing when no link is parked', () => {
expect(routeParkedSignInLink(0)).toBe('none');
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none');
});
it('leaves a link the dialog is asking about alone', () => {
acceptSignInLink(ATTACKER);
const shown = store().take()!;
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none');
expect(store().confirm(shown.id)).not.toBeNull();
});
});
+54
View File
@@ -9,8 +9,15 @@ import type { RootStackParamList } from './types';
import { setPendingSettingsTab } from './pending-settings-tab';
import { setPendingCalendarOpen } from './pending-calendar-open';
import { setPendingMailSearch } from './pending-mail-search';
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
import { MAX_ACCOUNTS } from '../lib/account-utils';
export const APP_SCHEME = 'bulwarkmobile';
// The webmail's "Link Mobile App" links (`bulwarkmail://pair?server=…&code=…`)
// and server-bootstrap links (`bulwarkmail://connect?server=…`). They sign in
// rather than navigate, so they never become a DeepLink.
export const SIGN_IN_SCHEME = 'bulwarkmail';
const UNIFIED_ROLES = ['inbox', 'sent', 'drafts', 'junk', 'archive', 'trash'] as const;
const UNIFIED_VIEWS = ['all', 'unread', 'starred'] as const;
@@ -74,6 +81,7 @@ function splitUrl(url: string): { segments: string[]; search: URLSearchParams }
export function parseDeepLink(url: string): DeepLink | null {
if (!url) return null;
if (isSignInSchemeUrl(url)) return null;
if (/^mailto:/i.test(url)) {
const parsed = parseMailtoUrl(url);
if (!parsed) return null;
@@ -166,6 +174,52 @@ export function parseDeepLink(url: string): DeepLink | null {
}
}
function isSignInSchemeUrl(url: string): boolean {
return url.trim().toLowerCase().startsWith(`${SIGN_IN_SCHEME}:`);
}
/** A `bulwarkmail://pair|connect?…` link, parsed; null for any other URL. */
export function parseSignInLink(url: string | null | undefined): QrLoginPayload | null {
if (!url || !isSignInSchemeUrl(url)) return null;
return parseQrLoginPayload(url);
}
/**
* Park a sign-in link the OS delivered (a tapped link, or another app firing
* the intent) for the login screen, which takes it (the signed-out one, or
* Add account when someone is signed in) and asks the user before running
* it: whoever sent it chose the webmail and the code. A pairing link for a
* plain-http webmail is parked as a refusal instead, for the login screen to
* say why nothing happens. Returns whether `url` was a sign-in link.
*/
export function acceptSignInLink(url: string | null | undefined): boolean {
const payload = parseSignInLink(url);
if (payload) {
setPendingSignInLink(payload, 'external');
return true;
}
const refusal = url && isSignInSchemeUrl(url) ? insecurePairingLinkError(url) : null;
if (!refusal) return false;
usePendingSignInLinkStore.getState().refuse(refusal);
return true;
}
/**
* Signed in, where a parked sign-in link goes: Add account, whose login
* screen takes it, or, with no room for another account, nowhere: the link
* is dropped before a code is spent and the caller shows the limit. 'none'
* when nothing is parked (taken meanwhile by an Add account already open).
*/
export function routeParkedSignInLink(accountCount: number): 'add-account' | 'account-limit' | 'none' {
const links = usePendingSignInLinkStore.getState();
if (!links.pending && !links.refusal) return 'none';
if (accountCount >= MAX_ACCOUNTS) {
links.dropParked();
return 'account-limit';
}
return 'add-account';
}
export interface DeepLinkNavigator {
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
+115
View File
@@ -0,0 +1,115 @@
import { create } from 'zustand';
import type { PairingError, QrLoginPayload } from '../lib/oauth';
// A tapped `bulwarkmail://` sign-in link arrives before the login screen is
// up (cold start, or signed in with Add account not open yet). It is parked
// here and taken by LoginScreen, which runs it like a scanned code. Taking
// clears it, so one link signs in once however often it is delivered.
//
// Where a link came from decides whether the user is asked first:
// - `external`: the OS handed it to the app. Any web page (one tap) or any
// installed app (no tap at all) can fire one, with a webmail and a code of
// its own choosing, so the user confirms before it is redeemed or opened.
// - `in-app`: scanned with the app's scanner or pasted into its paste box.
// The user just did that in the app, so it runs straight away.
export type SignInLinkSource = 'external' | 'in-app';
export interface PendingSignInLink {
// Tells one delivery from the next, so a dialog can only ever confirm the
// link it named.
id: number;
payload: QrLoginPayload;
source: SignInLinkSource;
}
export interface TakenSignInLink {
id: number;
payload: QrLoginPayload;
// Show the confirmation and run the link only on Continue (`confirm`).
needsConfirmation: boolean;
}
export function signInLinkNeedsConfirmation(source: SignInLinkSource): boolean {
return source !== 'in-app';
}
interface PendingSignInLinkState {
// Parked, not taken by a login screen yet.
pending: PendingSignInLink | null;
// Taken and shown to the user, waiting for Continue or Cancel.
awaitingConfirmation: PendingSignInLink | null;
// A tapped link that is refused outright (a pairing code for a plain-http
// webmail): nothing to run or confirm, the login screen says why.
refusal: PairingError | null;
/** Park a link. Returns false when it was dropped (a dialog is open). */
park: (payload: QrLoginPayload, source: SignInLinkSource) => boolean;
/**
* Take the parked link. One that needs confirmation moves to
* `awaitingConfirmation`; run it only with what `confirm` returns.
*/
take: () => TakenSignInLink | null;
/** Continue: the payload to run, or null when that link is gone. */
confirm: (id: number) => QrLoginPayload | null;
/**
* Cancel: forget the link, so nothing redeems it later. Returns whether it
* was still waiting.
*/
discard: (id: number) => boolean;
refuse: (refusal: PairingError) => void;
takeRefusal: () => PairingError | null;
/** Drop what is parked (not a link a dialog is asking about). */
dropParked: () => boolean;
clear: () => void;
}
let nextId = 1;
export const usePendingSignInLinkStore = create<PendingSignInLinkState>((set, get) => ({
pending: null,
awaitingConfirmation: null,
refusal: null,
park: (payload, source) => {
// While the user is asked about one link, another is dropped rather
// than queued behind it or swapped in: Continue must run the link the
// dialog names and nothing else, and a link fired while the dialog is
// open must not pop up right after Cancel.
if (get().awaitingConfirmation) return false;
set({ pending: { id: nextId++, payload, source } });
return true;
},
take: () => {
const { pending, awaitingConfirmation } = get();
if (!pending || awaitingConfirmation) return null;
const needsConfirmation = signInLinkNeedsConfirmation(pending.source);
set({ pending: null, awaitingConfirmation: needsConfirmation ? pending : null });
return { id: pending.id, payload: pending.payload, needsConfirmation };
},
confirm: (id) => {
const link = get().awaitingConfirmation;
if (!link || link.id !== id) return null;
set({ awaitingConfirmation: null });
return link.payload;
},
discard: (id) => {
if (get().awaitingConfirmation?.id !== id) return false;
set({ awaitingConfirmation: null });
return true;
},
refuse: (refusal) => set({ refusal }),
takeRefusal: () => {
const refusal = get().refusal;
if (refusal) set({ refusal: null });
return refusal;
},
dropParked: () => {
const { pending, refusal } = get();
if (!pending && !refusal) return false;
set({ pending: null, refusal: null });
return true;
},
clear: () => set({ pending: null, awaitingConfirmation: null, refusal: null }),
}));
export function setPendingSignInLink(payload: QrLoginPayload, source: SignInLinkSource): boolean {
return usePendingSignInLinkStore.getState().park(payload, source);
}
+202 -44
View File
@@ -1,11 +1,18 @@
import React from 'react';
import { BackHandler } from 'react-native';
import { Alert, BackHandler } from 'react-native';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
import { useLocaleStore } from '../stores/locale-store';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { PasteSignInLinkModal } from '../components/PasteSignInLinkModal';
import {
insecurePairingLinkError,
parseQrLoginPayload,
signInLinkHost,
type QrLoginPayload,
} from '../lib/oauth';
import { usePendingSignInLinkStore } from '../navigation/pending-sign-in-link';
import {
discoverServerForEmail,
emailDomain,
@@ -31,6 +38,31 @@ type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
// The question asked before running a sign-in link from outside the app:
// which webmail it talks to, and whether it adds an account (and switches to
// it) or signs in.
function signInLinkConfirmation(
payload: QrLoginPayload,
isAddMode: boolean,
t: TranslateFn,
): { title: string; message: string } {
const host = signInLinkHost(payload.webmailUrl);
if (payload.kind === 'connect') {
return {
title: t('login.mobile.link_confirm_connect_title', 'Open the sign-in page of {host}?', { host }),
message: isAddMode
? t('login.mobile.link_confirm_connect_add', 'A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.', { host })
: t('login.mobile.link_confirm_connect', 'A link asks to sign in on this page. Only continue if you use {host} for your mail.', { host }),
};
}
return {
title: t('login.mobile.link_confirm_title', 'Sign in with a link?'),
message: isAddMode
? t('login.mobile.link_confirm_pair_add', 'This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.', { host })
: t('login.mobile.link_confirm_pair', 'This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.', { host }),
};
}
/**
* Sign-in, one question per screen, cheapest question first.
*
@@ -69,6 +101,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const [searching, setSearching] = React.useState(false);
const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
const [scannerVisible, setScannerVisible] = React.useState(false);
const [pasteVisible, setPasteVisible] = React.useState(false);
// The webmail a sign-in code is being redeemed at, for the waiting screen
// (in add mode the server fields still name the signed-in account's host).
const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState<string | null>(null);
// Addresses typed before on this device (last 5) plus the registry's, so a
// returning user taps instead of retyping. Never contains passwords.
@@ -131,6 +167,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setScannerVisible(false);
return true;
}
if (pasteVisible) {
setPasteVisible(false);
return true;
}
if (history.length > 0) {
goBack();
return true;
@@ -142,7 +182,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
return false;
});
return () => subscription.remove();
}, [busy, searching, scannerVisible, history.length, goBack, isAddMode, onCancel]);
}, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel]);
// ── flows ──────────────────────────────────────────────
@@ -160,6 +200,27 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
[isAddMode, onLogin],
);
// The webmail hands back the password even when the account needs a second
// factor (a hand-off, or a sign-in code on a server without app passwords);
// finish the sign-in here with a code instead of failing (the user already
// proved the password once). Returns whether it took over.
const continueWithSecondFactor = React.useCallback(
(err: unknown, target: string): boolean => {
const pending = useAuthStore.getState().pendingTotpLogin;
if (!(err instanceof Error && err.name === 'TotpRequiredError' && pending)) return false;
setEmail(pending.username);
setPassword(pending.password);
setServerUrl(pending.serverUrl);
setTotpRequired(true);
setBusy(null);
setNotice(describeLoginError(err, { serverUrl: target, t }));
setHistory((prev) => [...prev, step]);
setStep('password');
return true;
},
[step, t],
);
const runHandoff = React.useCallback(
async (target: string) => {
setNotice(null);
@@ -171,27 +232,13 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// that's a cancellation, not a failure, so leave them where they were.
finishIfSignedIn(wasAuthenticated);
} catch (err) {
// The webmail hands back the password even when the account needs a
// second factor; finish the sign-in here with a code instead of
// failing (the user already proved the password once).
const pending = useAuthStore.getState().pendingTotpLogin;
if (err instanceof Error && err.name === 'TotpRequiredError' && pending) {
setEmail(pending.username);
setPassword(pending.password);
setServerUrl(pending.serverUrl);
setTotpRequired(true);
setBusy(null);
setNotice(describeLoginError(err, { serverUrl: target, t }));
setHistory((prev) => [...prev, step]);
setStep('password');
return;
}
if (continueWithSecondFactor(err, target)) return;
setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
}
},
[finishIfSignedIn, isAddMode, loginViaWebmail, step, t],
[continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaWebmail, t],
);
const handleEmailContinue = React.useCallback(async () => {
@@ -275,17 +322,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]);
const handleScanned = React.useCallback(
async (data: string) => {
// A sign-in code, however it arrived: scanned, pasted, or a tapped
// `bulwarkmail://` link.
const handleSignInLink = React.useCallback(
async (payload: QrLoginPayload) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
setNotice({
title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"),
detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link Mobile App to show one.'),
});
return;
}
setPasteVisible(false);
if (payload.kind === 'connect') {
// Server-bootstrap code: it carries the address, the user still
@@ -296,34 +338,156 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
return;
}
// Cross-device pairing code: redeem it for tokens, no browser needed.
// Cross-device pairing code: redeem it, no browser needed.
setNotice(null);
setPairingWebmailUrl(payload.webmailUrl);
setBusy('pairing');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
if (continueWithSecondFactor(err, payload.webmailUrl)) return;
// The error names its own host: the webmail for a failed redeem, the
// mail server when connecting afterwards failed.
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t }));
} finally {
setBusy(null);
setPairingWebmailUrl(null);
}
},
[finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
[continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
);
const handleScanned = React.useCallback(
(data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
// A pairing code for a plain-http webmail: say why it won't be used.
const insecure = insecurePairingLinkError(data);
if (insecure) {
setNotice(describeLoginError(insecure, { t }));
return;
}
setNotice({
title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"),
detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link Mobile App to show one.'),
});
return;
}
void handleSignInLink(payload);
},
[handleSignInLink, t],
);
// A sign-in link from outside the app (a tapped link, or one another app
// fired) is parked by App until a login screen is free to take it; taking
// clears it, so one link runs once. Whoever sent it chose the webmail and
// the code, so the user confirms first: nothing is redeemed or opened
// before Continue, and Cancel (or dismissing the dialog) forgets the link.
// Scanned and pasted links skip this: they call handleSignInLink directly.
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null);
// The dialog answers after later renders; run the current callbacks.
const latestRef = React.useRef({ handleSignInLink, onCancel });
React.useEffect(() => {
latestRef.current = { handleSignInLink, onCancel };
}, [handleSignInLink, onCancel]);
const mountedRef = React.useRef(false);
const confirmingLinkIdRef = React.useRef<number | null>(null);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
// Gone while the dialog is up: forget its link, so a late Continue runs
// nothing and later links are not dropped as "dialog open". Checked a
// tick later, as a StrictMode rehearsal remounts straight away.
setTimeout(() => {
const id = confirmingLinkIdRef.current;
if (!mountedRef.current && id !== null) usePendingSignInLinkStore.getState().discard(id);
}, 0);
};
}, []);
React.useEffect(() => {
if (!signInLinkPending || busy || searching) return;
const taken = usePendingSignInLinkStore.getState().take();
if (!taken) return;
if (!taken.needsConfirmation) {
void handleSignInLink(taken.payload);
return;
}
// Add account opened for this link closes again on Cancel; one the user
// was already working through stays where it was.
const leaveOnCancel = isAddMode && history.length === 0;
confirmingLinkIdRef.current = taken.id;
let answered = false;
const answer = (proceed: boolean) => {
if (answered) return;
answered = true;
confirmingLinkIdRef.current = null;
const store = usePendingSignInLinkStore.getState();
if (!mountedRef.current) {
store.discard(taken.id);
return;
}
if (proceed) {
// Only the link this dialog named, and only if it is still waiting.
const payload = store.confirm(taken.id);
if (payload) void latestRef.current.handleSignInLink(payload);
return;
}
if (store.discard(taken.id) && leaveOnCancel) latestRef.current.onCancel?.();
};
const { title, message } = signInLinkConfirmation(taken.payload, isAddMode, t);
Alert.alert(
title,
message,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) },
{ text: t('login.mobile.continue', 'Continue'), onPress: () => answer(true) },
],
// Android back or a tap outside the dialog counts as Cancel.
{ cancelable: true, onDismiss: () => answer(false) },
);
}, [busy, handleSignInLink, history.length, isAddMode, searching, signInLinkPending, t]);
// A tapped link that is refused outright (a pairing code for a plain-http
// webmail) ran nothing; say why, as for a scanned one.
const signInLinkRefused = usePendingSignInLinkStore((s) => s.refusal !== null);
React.useEffect(() => {
if (!signInLinkRefused || busy || searching) return;
const refusal = usePendingSignInLinkStore.getState().takeRefusal();
if (refusal) setNotice(describeLoginError(refusal, { t }));
}, [busy, searching, signInLinkRefused, t]);
// ── render ─────────────────────────────────────────────
if (busy) {
const pairing = busy === 'pairing';
return (
<SigningInStep
phase={busy}
serverUrl={serverUrl || knownServerUrl}
email={email.trim() || null}
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
email={pairing ? null : email.trim() || null}
/>
);
}
const modals = (
<>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={handleScanned}
/>
<PasteSignInLinkModal
visible={pasteVisible}
onClose={() => setPasteVisible(false)}
onSubmit={(payload) => void handleSignInLink(payload)}
/>
</>
);
const canGoBack = history.length > 0;
const shellProps = {
onBack: canGoBack ? goBack : undefined,
@@ -344,6 +508,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
knownServerUrl={isAddMode ? knownServerUrl : null}
notice={chooseNotice}
onScan={() => setScannerVisible(true)}
onPaste={() => setPasteVisible(true)}
onUseEmail={() => goTo('email')}
onUseKnownServer={() => {
if (!knownServerUrl) return;
@@ -358,11 +523,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}}
/>
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => void handleScanned(data)}
/>
{modals}
</>
);
}
@@ -398,6 +559,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}}
onSubmit={handleServerContinue}
onScan={() => setScannerVisible(true)}
onPaste={() => setPasteVisible(true)}
failedDomain={failedDomain}
notice={notice}
/>
@@ -442,11 +604,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
) : null}
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => void handleScanned(data)}
/>
{modals}
</>
);
}
+34 -15
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Image } from 'react-native';
import { QrCode, Mail, Plus, Server } from 'lucide-react-native';
import { ClipboardPaste, QrCode, Mail, Plus, Server } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store';
@@ -19,6 +19,8 @@ interface ChooseStepProps {
/** Host of the signed-in account, offered as a shortcut in add mode. */
knownServerUrl: string | null;
onScan: () => void;
/** Paste the sign-in link instead of scanning its code. */
onPaste: () => void;
onUseEmail: () => void;
onUseKnownServer: () => void;
onManualSetup: () => void;
@@ -46,6 +48,7 @@ export default function ChooseStep({
accounts,
knownServerUrl,
onScan,
onPaste,
onUseEmail,
onUseKnownServer,
onManualSetup,
@@ -57,6 +60,16 @@ export default function ChooseStep({
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
// Sits right under the scan tile, wherever that is.
const pasteTile = (
<OptionTile
title={t('login.mobile.paste_title', 'Paste sign-in link')}
renderIcon={(color, size) => <ClipboardPaste size={size} color={color} />}
onPress={onPaste}
disabled={disabled}
/>
);
return (
<View style={styles.root}>
{isAddMode ? (
@@ -115,14 +128,17 @@ export default function ChooseStep({
disabled={disabled}
/>
) : (
<OptionTile
emphasis="primary"
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
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} />}
onPress={onScan}
disabled={disabled}
/>
<>
<OptionTile
emphasis="primary"
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
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} />}
onPress={onScan}
disabled={disabled}
/>
{pasteTile}
</>
)}
<OptionTile
@@ -134,12 +150,15 @@ export default function ChooseStep({
/>
{isAddMode && knownServerUrl ? (
<OptionTile
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
<>
<OptionTile
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
{pasteTile}
</>
) : null}
</View>
+17 -7
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Globe, QrCode } from 'lucide-react-native';
import { ClipboardPaste, Globe, QrCode } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
@@ -12,6 +12,7 @@ interface ServerStepProps {
onChange: (value: string) => void;
onSubmit: () => void;
onScan: () => void;
onPaste: () => void;
/** Set when we got here because discovery came back empty for this domain. */
failedDomain?: string | null;
notice?: { title: string; detail?: string } | null;
@@ -27,6 +28,7 @@ export default function ServerStep({
onChange,
onSubmit,
onScan,
onPaste,
failedDomain,
notice,
}: ServerStepProps) {
@@ -67,12 +69,20 @@ export default function ServerStep({
{t('login.mobile.continue', 'Continue')}
</Button>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")}
</Text>
</Pressable>
<View>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow} accessibilityRole="button">
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")}
</Text>
</Pressable>
<Pressable onPress={onPaste} hitSlop={8} style={styles.scanRow} accessibilityRole="button">
<ClipboardPaste size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_paste_hint', 'Have a sign-in link instead? Paste it here.')}
</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// A full account registry (MAX_ACCOUNTS): adding an account is refused before
// a pairing code is spent or a browser sign-in starts, and a sign-in that
// finds the registry full only once it has connected hands the live account
// its connection back instead of leaving the shared client signed in as the
// new account.
const session = { apiUrl: 'https://mail.example.com/jmap/' };
const request = vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
methodResponses: calls.map(([name, , id]) => {
if (name === 'Identity/get') return [name, { list: [{ id: 'i1', name: 'Ada', email: 'ada@example.com' }] }, id];
if (name === 'Mailbox/get') return [name, { list: [{ id: 'mb-in', name: 'Inbox', role: 'inbox' }], state: 'mbs-1' }, id];
return ['error', { type: 'forbidden' }, id];
}),
}));
const LIVE_SNAPSHOT = { session: 'live-session', credentials: 'live-credentials', accountId: 'live-jmap' };
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connect: vi.fn(async () => ({ apiUrl: 'https://mail.example.com/jmap/' })),
connectWithOAuth: vi.fn(async () => ({
session: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
accountId: 'ada@example.com@mail.example.com',
})),
snapshot: vi.fn(() => LIVE_SNAPSHOT),
restoreSnapshot: vi.fn(),
reset: vi.fn(),
clearAccountCredentials: vi.fn(async () => undefined),
onAuthFailure: vi.fn(() => () => undefined),
onTokenRefresh: vi.fn(() => () => undefined),
hasAccountCapability: vi.fn(() => false),
getAccountName: () => undefined,
getSharedMailAccounts: () => [],
request: (calls: Array<[string, Record<string, unknown>, string]>) => request(calls),
accountId: 'acc-1',
isConnected: true,
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
serverUrl: 'https://mail.example.com',
},
AuthenticationError: class AuthenticationError extends Error {},
NetworkError: class NetworkError extends Error {},
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
vi.mock('../../lib/oauth', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/oauth')>()),
redeemPairingCode: vi.fn(),
}));
vi.mock('../../lib/oauth-native', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/oauth-native')>()),
discoverOAuthMetadata: vi.fn(),
loginWithPkce: vi.fn(),
probeWebmail: vi.fn(),
}));
import * as WebBrowser from 'expo-web-browser';
import { jmapClient } from '../../api/jmap-client';
import { redeemPairingCode, type HandoffResult } from '../../lib/oauth';
import { discoverOAuthMetadata, loginWithPkce, probeWebmail } from '../../lib/oauth-native';
import { AccountLimitError, MAX_ACCOUNTS } from '../../lib/account-utils';
import { useAccountStore, type AccountEntry } from '../account-store';
import { useAuthStore } from '../auth-store';
const WEBMAIL = 'https://webmail.example.org/mail';
const SERVER = 'https://mail.example.com';
const NEW_ID = 'ada@example.com@mail.example.com';
const redeem = redeemPairingCode as ReturnType<typeof vi.fn>;
const connect = jmapClient.connect as unknown as ReturnType<typeof vi.fn>;
const connectWithOAuth = jmapClient.connectWithOAuth as unknown as ReturnType<typeof vi.fn>;
const oauthResult: HandoffResult = {
flow: 'oauth',
serverUrl: SERVER,
tokens: {
accessToken: 'at',
refreshToken: 'sealed-refresh',
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
clientId: 'bulwark-webmail',
source: 'pairing',
},
};
let codeCounter = 0;
function newCode(): string {
codeCounter += 1;
return `ffff${codeCounter.toString(16).padStart(60, '0')}`;
}
function entry(i: number): AccountEntry {
return {
id: `user${i}@mail${i}.example.net`,
serverUrl: `https://mail${i}.example.net`,
username: `user${i}`,
displayName: `User ${i}`,
email: `user${i}@example.net`,
avatarColor: '#336699',
lastLoginAt: 0,
isConnected: true,
hasError: false,
isDefault: i === 0,
};
}
// `count` accounts, the first one live.
function registry(count: number, extra: AccountEntry[] = []): void {
const accounts = [...Array.from({ length: count - extra.length }, (_, i) => entry(i)), ...extra];
useAccountStore.setState({ accounts, activeAccountId: accounts[0].id, defaultAccountId: accounts[0].id });
useAuthStore.setState({
isAuthenticated: true,
isLoading: false,
error: null,
session: session as never,
activeAccountId: accounts[0].id,
username: accounts[0].username,
serverUrl: accounts[0].serverUrl,
});
}
// What a sign-in finishing elsewhere meanwhile does: takes the last free slot.
function fillLastSlotDuring<T>(result: T): () => Promise<T> {
return async () => {
const { accounts } = useAccountStore.getState();
useAccountStore.setState({ accounts: [...accounts, entry(99)] });
return result;
};
}
beforeEach(() => {
vi.clearAllMocks();
(probeWebmail as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(discoverOAuthMetadata as ReturnType<typeof vi.fn>).mockResolvedValue(null);
});
describe('a full registry refuses before anything is spent', () => {
it('keeps a pairing code unredeemed when adding an account', async () => {
registry(MAX_ACCOUNTS);
const code = newCode();
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, code, { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
expect(redeem).not.toHaveBeenCalled();
expect(jmapClient.snapshot).not.toHaveBeenCalled();
expect(connectWithOAuth).not.toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({
isAuthenticated: true,
isLoading: false,
activeAccountId: entry(0).id,
error: `Maximum of ${MAX_ACCOUNTS} accounts reached`,
});
// Not spent: once there is room, the same code still works.
registry(MAX_ACCOUNTS - 1);
redeem.mockResolvedValueOnce(oauthResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, code, { addAccount: true });
expect(redeem).toHaveBeenCalledWith(WEBMAIL, code);
expect(useAccountStore.getState().accounts).toHaveLength(MAX_ACCOUNTS);
expect(useAuthStore.getState().activeAccountId).toBe(NEW_ID);
});
it('does not open the webmail or OAuth sign-in when adding an account', async () => {
registry(MAX_ACCOUNTS);
await expect(useAuthStore.getState().loginViaWebmail(SERVER, { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
await expect(useAuthStore.getState().loginViaOAuth(SERVER, { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
expect(probeWebmail).not.toHaveBeenCalled();
expect(loginWithPkce).not.toHaveBeenCalled();
expect(WebBrowser.openAuthSessionAsync).not.toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, activeAccountId: entry(0).id });
});
it('refuses a password sign-in for a new account before connecting', async () => {
registry(MAX_ACCOUNTS);
await expect(useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
expect(connect).not.toHaveBeenCalled();
expect(jmapClient.restoreSnapshot).not.toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, activeAccountId: entry(0).id });
});
it('still signs in again to an account it already holds', async () => {
const ada = { ...entry(5), id: NEW_ID, serverUrl: SERVER, username: 'ada@example.com' };
registry(MAX_ACCOUNTS, [ada]);
await useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true });
expect(connect).toHaveBeenCalled();
expect(useAccountStore.getState().accounts).toHaveLength(MAX_ACCOUNTS);
expect(useAuthStore.getState().activeAccountId).toBe(NEW_ID);
});
});
describe('a registry that fills up while connecting', () => {
it('gives the live account its connection back after an OAuth sign-in (pairing)', async () => {
registry(MAX_ACCOUNTS - 1);
redeem.mockResolvedValueOnce(oauthResult);
connectWithOAuth.mockImplementationOnce(fillLastSlotDuring({
session: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
accountId: NEW_ID,
}));
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith(LIVE_SNAPSHOT);
// The new account's stored credentials don't outlive it.
expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith(NEW_ID);
expect(useAccountStore.getState().accounts.map((a) => a.id)).not.toContain(NEW_ID);
expect(useAccountStore.getState().activeAccountId).toBe(entry(0).id);
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, isLoading: false, activeAccountId: entry(0).id });
});
it('gives the live account its connection back after a password sign-in', async () => {
registry(MAX_ACCOUNTS - 1);
connect.mockImplementationOnce(fillLastSlotDuring({ apiUrl: 'https://mail.example.com/jmap/' }));
await expect(useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith(LIVE_SNAPSHOT);
expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith(NEW_ID);
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, isLoading: false, activeAccountId: entry(0).id });
expect(useAuthStore.getState().error).toBe(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
});
it('keeps the credentials of an account it already held', async () => {
const ada = { ...entry(5), id: NEW_ID, serverUrl: SERVER, username: 'ada@example.com' };
registry(MAX_ACCOUNTS - 1, [ada]);
// Held, so the update fits; make registering fail another way.
const addAccount = useAccountStore.getState().addAccount;
useAccountStore.setState({ addAccount: () => { throw new AccountLimitError(); } });
try {
await expect(useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true }))
.rejects.toBeInstanceOf(AccountLimitError);
} finally {
useAccountStore.setState({ addAccount });
}
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith(LIVE_SNAPSHOT);
expect(jmapClient.clearAccountCredentials).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Signing in with a code from the webmail's Link Mobile App: both answers the
// webmail can give (an OAuth grant, or an app password), first sign-in and
// Add account, the one-redemption guard, and the error once the code is spent.
const session = { apiUrl: 'https://mail.example.com/jmap/' };
const request = vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
methodResponses: calls.map(([name, , id]) => {
if (name === 'Identity/get') return [name, { list: [{ id: 'i1', name: 'Ada', email: 'ada@example.com' }] }, id];
if (name === 'Mailbox/get') return [name, { list: [{ id: 'mb-in', name: 'Inbox', role: 'inbox' }], state: 'mbs-1' }, id];
return ['error', { type: 'forbidden' }, id];
}),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connect: vi.fn(async () => ({ apiUrl: 'https://mail.example.com/jmap/' })),
connectWithOAuth: vi.fn(async () => ({
session: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
accountId: 'app-account-1',
})),
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
restoreSnapshot: vi.fn(),
onAuthFailure: vi.fn(() => () => undefined),
onTokenRefresh: vi.fn(() => () => undefined),
hasAccountCapability: vi.fn(() => false),
getAccountName: () => undefined,
getSharedMailAccounts: () => [],
request: (calls: Array<[string, Record<string, unknown>, string]>) => request(calls),
accountId: 'acc-1',
isConnected: true,
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'ada@example.com',
serverUrl: 'https://mail.example.com',
},
AuthenticationError: class AuthenticationError extends Error {},
NetworkError: class NetworkError extends Error {},
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
vi.mock('../../lib/oauth', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/oauth')>()),
redeemPairingCode: vi.fn(),
}));
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
import { PairingError, redeemPairingCode, type HandoffResult } from '../../lib/oauth';
import { useAuthStore } from '../auth-store';
const WEBMAIL = 'https://webmail.example.org/mail';
const SERVER = 'https://mail.example.com';
const redeem = redeemPairingCode as ReturnType<typeof vi.fn>;
const connect = jmapClient.connect as unknown as ReturnType<typeof vi.fn>;
const connectWithOAuth = jmapClient.connectWithOAuth as unknown as ReturnType<typeof vi.fn>;
let codeCounter = 0;
// Every test uses fresh codes: the redeemed-code guard lives for the process.
function newCode(): string {
codeCounter += 1;
return codeCounter.toString(16).padStart(64, '0');
}
const oauthResult: HandoffResult = {
flow: 'oauth',
serverUrl: SERVER,
tokens: {
accessToken: 'at',
refreshToken: 'sealed-refresh',
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
clientId: 'bulwark-webmail',
source: 'pairing',
},
};
const passwordResult: HandoffResult = {
flow: 'password',
serverUrl: SERVER,
username: 'ada@example.com',
password: 'app-pass-123',
};
function signedOut() {
useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null, session: null });
}
function signedIn() {
useAuthStore.setState({ isAuthenticated: true, isLoading: false, error: null, session: session as never });
}
beforeEach(() => {
vi.clearAllMocks();
signedOut();
});
describe('loginViaPairing with an OAuth grant', () => {
it('signs in with the redeemed tokens', async () => {
redeem.mockResolvedValueOnce(oauthResult);
const code = newCode();
await useAuthStore.getState().loginViaPairing(WEBMAIL, code);
expect(redeem).toHaveBeenCalledWith(WEBMAIL, code);
expect(connectWithOAuth).toHaveBeenCalledWith(SERVER, oauthResult.flow === 'oauth' ? oauthResult.tokens : null);
expect(jmapClient.snapshot).not.toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, isLoading: false, error: null, serverUrl: SERVER });
});
it('adds an account next to the signed-in one', async () => {
signedIn();
redeem.mockResolvedValueOnce(oauthResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true });
// The live connection is kept until the new one works.
expect(jmapClient.snapshot).toHaveBeenCalled();
expect(connectWithOAuth).toHaveBeenCalled();
expect(jmapClient.restoreSnapshot).not.toHaveBeenCalled();
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
});
describe('loginViaPairing with an app password', () => {
it('signs in through the password login', async () => {
redeem.mockResolvedValueOnce(passwordResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined);
expect(connectWithOAuth).not.toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER });
});
it('passes add-account mode on to login', async () => {
signedIn();
redeem.mockResolvedValueOnce(passwordResult);
const original = useAuthStore.getState().login;
const login = vi.fn(async () => undefined);
useAuthStore.setState({ login });
try {
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true });
expect(login).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: true });
redeem.mockResolvedValueOnce(passwordResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
expect(login).toHaveBeenLastCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: undefined });
} finally {
useAuthStore.setState({ login: original });
}
});
it('adds an account through the real login, keeping the live one on failure', async () => {
signedIn();
redeem.mockResolvedValueOnce(passwordResult);
connect.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
const err = await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true })
.then(() => null, (e: unknown) => e);
expect(jmapClient.snapshot).toHaveBeenCalled();
expect(jmapClient.restoreSnapshot).toHaveBeenCalled();
expect(err).toBeInstanceOf(PairingError);
expect(err).toMatchObject({ reason: 'connect_failed', host: 'mail.example.com' });
});
it('leaves a second-factor prompt to the login screen', async () => {
redeem.mockResolvedValueOnce(passwordResult);
const totp = new Error('TOTP required');
totp.name = 'TotpRequiredError';
connect.mockRejectedValueOnce(totp);
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode())).rejects.toBe(totp);
expect(useAuthStore.getState().pendingTotpLogin).toEqual({
serverUrl: SERVER, username: 'ada@example.com', password: 'app-pass-123',
});
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
describe('loginViaPairing guards', () => {
it('redeems a code once, even when it arrives twice', async () => {
let resolve!: (value: HandoffResult) => void;
redeem.mockImplementationOnce(() => new Promise<HandoffResult>((r) => { resolve = r; }));
const code = newCode();
const first = useAuthStore.getState().loginViaPairing(WEBMAIL, code);
// Delivered again while the first is still on its way (a re-read launch URL).
const second = await useAuthStore.getState().loginViaPairing(WEBMAIL, code).then(() => null, (e: unknown) => e);
expect(second).toBeInstanceOf(PairingError);
expect(second).toMatchObject({ reason: 'used', host: 'webmail.example.org' });
resolve(oauthResult);
await first;
expect(useAuthStore.getState().isAuthenticated).toBe(true);
// And after it went through.
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, code)).rejects.toMatchObject({ reason: 'used' });
expect(redeem).toHaveBeenCalledTimes(1);
});
it('lets a code be tried again when redeeming it failed', async () => {
const code = newCode();
redeem.mockRejectedValueOnce(new PairingError('network', 'Could not reach webmail.example.org', { host: 'webmail.example.org' }));
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, code)).rejects.toMatchObject({ reason: 'network' });
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, error: 'Could not reach webmail.example.org' });
redeem.mockResolvedValueOnce(oauthResult);
await useAuthStore.getState().loginViaPairing(WEBMAIL, code);
expect(redeem).toHaveBeenCalledTimes(2);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('says the code was used but connecting failed, naming the mail server', async () => {
signedIn();
redeem.mockResolvedValueOnce(oauthResult);
connectWithOAuth.mockRejectedValueOnce(new AuthenticationError('401'));
const err = await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true })
.then(() => null, (e: unknown) => e);
expect(err).toBeInstanceOf(PairingError);
expect(err).toMatchObject({ reason: 'connect_failed', host: 'mail.example.com' });
expect((err as PairingError).cause).toBeInstanceOf(AuthenticationError);
expect(jmapClient.restoreSnapshot).toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, isAuthenticated: true });
expect(useAuthStore.getState().error).toMatch(/Authentication rejected by server/);
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPersistStorage } from './persist-storage';
import { generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
import { AccountLimitError, generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
import { generateAvatarColor } from '../lib/avatar-utils';
export interface AccountEntry {
@@ -72,7 +72,7 @@ export const useAccountStore = create<AccountState>()(
}
if (state.accounts.length >= MAX_ACCOUNTS) {
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
throw new AccountLimitError();
}
const isDefault = state.accounts.length === 0;
+150 -42
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
import { jmapClient, AuthenticationError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
import type { JMAPSession } from '../api/types';
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
import { useAccountStore } from './account-store';
@@ -12,8 +12,15 @@ import { flushPersistedWrites } from './persist-storage';
import { clearEmailDetailCache } from '../lib/email-detail-cache';
import { clearBodyDocuments } from '../lib/email-body-document';
import { clearBodyHeights } from '../lib/body-heights';
import { generateAccountId } from '../lib/account-utils';
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, HandoffError, type HandoffResult } from '../lib/oauth';
import { AccountLimitError, generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
import {
runWebmailHandoff,
redeemPairingCode,
HandoffCancelledError,
HandoffError,
PairingError,
type HandoffResult,
} from '../lib/oauth';
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
import {
teardownPushNotifications,
@@ -153,8 +160,51 @@ function refetchFeatureStores(): void {
}
}
// Pairing codes this app process has redeemed or is redeeming. Codes are
// single-use, so a second attempt with the same one can only fail.
const pairingCodesSeen = new Set<string>();
function hostOfUrl(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/?#]/)[0];
}
// Room in the registry for the account a sign-in is about to add. One it
// already holds is an update and always fits; without an id (a code not yet
// redeemed, a browser sign-in not yet back) a full registry means no.
function assertRoomForAccount(accountId?: string): void {
const { accounts } = useAccountStore.getState();
if (accountId && accounts.some((a) => a.id === accountId)) return;
if (accounts.length >= MAX_ACCOUNTS) throw new AccountLimitError();
}
// Adding an account to a full registry: refuse before a sign-in code is
// spent or a browser sign-in started, since what they buy can't be kept.
function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: { addAccount?: boolean }): void {
if (!opts?.addAccount) return;
try {
assertRoomForAccount();
} catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) });
throw err;
}
}
// connect() points the shared client at the new account and stores its
// credentials before the registry has taken it. When registering it fails
// (the account limit), undo both: the live account, when one is being added
// to, gets the client back, and an account the registry never took keeps no
// credentials behind. Otherwise every request would go out as the new
// account while the app still shows the old one.
async function undoConnect(previous: ClientSnapshot | null, accountId: string, wasRegistered: boolean): Promise<void> {
if (previous) jmapClient.restoreSnapshot(previous);
else jmapClient.reset();
if (!wasRegistered) await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
}
// Best-effort RFC 7009 revocation of an account's refresh token on sign-out.
// A QR-paired phone shares the desktop's token, so those are left alone.
// QR-paired bundles are left alone: webmail up to 1.11 handed the phone the
// desktop's own refresh token, and newer ones hand out a separate grant whose
// refresh token only the webmail's token proxy understands.
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
try {
const entry = useAccountStore.getState().getAccountById(accountId);
@@ -222,21 +272,29 @@ async function completeOAuthHandoff(
throw err;
}
const { session, username, accountId } = connected;
const accountStore = useAccountStore.getState();
const wasRegistered = !!accountStore.getAccountById(accountId);
try {
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
} catch (err) {
await undoConnect(previous, accountId, wasRegistered);
throw err;
}
// Contacts/calendar are still single-bucket, so wipe those now that the
// new account is registered and the one the client serves.
if (previous) {
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
@@ -286,7 +344,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Adding an additional account: keep the live connection until the new
// sign-in succeeded so a typo doesn't kill the current session.
const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null;
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
try {
// A new account with no room left fails here, before connect swaps
// the live client over or stores credentials for it.
assertRoomForAccount(accountId);
const wasRegistered = !!useAccountStore.getState().getAccountById(accountId);
let session: JMAPSession;
try {
session = await jmapClient.connect(serverUrl, username, password, opts?.totp);
@@ -294,24 +357,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (previous) jmapClient.restoreSnapshot(previous);
throw err;
}
const accountStore = useAccountStore.getState();
try {
accountStore.addAccount({
serverUrl: serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
} catch (err) {
await undoConnect(previous, accountId, wasRegistered);
throw err;
}
// Contacts/calendar are still single-bucket, so wipe those now that
// the new account is the one the client serves.
// the new account is registered and the one the client serves.
if (previous) {
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
// Swap the email store's active view to the new account so the rest of
// this function (and refetchFeatureStores) writes to the right bucket.
@@ -343,6 +410,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loginViaWebmail: async (webmailUrl, opts) => {
set({ isLoading: true, error: null });
refuseAddWhenFull(set, opts);
// Discovery finds the *JMAP* host; a Bulwark webmail is not necessarily
// served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart
// lands on a 404 or the admin page, so check first and fall back to the
@@ -398,6 +466,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loginViaOAuth: async (serverUrl, opts) => {
set({ isLoading: true, error: null });
refuseAddWhenFull(set, opts);
const base = serverUrl.replace(/\/+$/, '');
let tokens;
try {
@@ -428,34 +497,73 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
loginViaPairing: async (webmailUrl, code, opts) => {
// A code is good for one redemption. The same link can reach us twice
// (a deep link re-read on launch, a double tap, a scan racing a paste);
// the second attempt must not spend a request only to be told "used".
if (pairingCodesSeen.has(code)) {
throw new PairingError('used', 'This pairing code was already used on this device', {
host: hostOfUrl(webmailUrl),
});
}
// With no room for another account the code stays unspent: redeeming it
// would only buy a sign-in the registry can't keep.
refuseAddWhenFull(set, opts);
pairingCodesSeen.add(code);
set({ isLoading: true, error: null });
let result;
let result: HandoffResult;
try {
result = await redeemPairingCode(webmailUrl, code);
} catch (err) {
// Not redeemed here: the server is the judge of a retry (it answers
// "used" if the request did get through).
pairingCodesSeen.delete(code);
const message = err instanceof Error ? err.message : 'Pairing failed';
set({ isLoading: false, error: message });
throw err;
}
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
// future server change can't silently mis-route credentials here.
if (result.flow !== 'oauth') {
set({ isLoading: false, error: 'Unexpected pairing response' });
throw new Error('Unexpected pairing response');
// The code is spent from here on. A failure to sign in with what it
// bought needs a new code, which the error says. Two failures keep their
// own error: a second factor the login screen can still ask for (servers
// without app passwords hand over the account password, which `login`
// keeps for that step), and the account limit, which a new code won't fix.
const serverHost = hostOfUrl(result.serverUrl);
const connectFailed = (err: unknown): unknown => {
if (err instanceof Error && (err.name === 'TotpRequiredError' || err instanceof AccountLimitError)) {
// `login` has settled the store already; the OAuth path has not.
if (get().isLoading) set({ isLoading: false, error: err.message });
return err;
}
const message = err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'Pairing sign-in failed';
const wrapped = new PairingError('connect_failed', `Signed in, but connecting failed: ${message}`, {
host: serverHost,
cause: err,
});
set({ isLoading: false, error: wrapped.message });
return wrapped;
};
if (result.flow === 'password') {
// An app password (or, for servers without them, the account
// password): the normal password sign-in, which also keeps the live
// account when adding another one fails.
try {
await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount });
} catch (err) {
throw connectFailed(err);
}
return;
}
try {
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'Pairing sign-in failed';
set({ isLoading: false, error: message });
throw err;
throw connectFailed(err);
}
},
+9 -11
View File
@@ -29,8 +29,6 @@ import type { EventItem, WidgetSnapshot } from '../snapshot';
import { chipBackground, normalizeHex, type WidgetPalette } from '../theme';
import type { Layout } from './types';
/** Surface's 1dp frame; Android adds a border to the padding, so it eats space. */
const FRAME = 1;
const HOUR = 3600000;
const GRID_START_HOUR = 8;
const GRID_END_HOUR = 18;
@@ -207,7 +205,7 @@ export const AgendaLayout: Layout = ({ s, p, f, now, height }) => {
);
}
// Header bar plus its 1dp rule.
const room = height - 2 * FRAME - headerHeight - 1;
const room = height - headerHeight - 1;
const { sections, hidden } = planAgenda(days, room);
const rows: React.JSX.Element[] = [];
sections.forEach((day, d) => {
@@ -366,12 +364,12 @@ export const WeekLayout: Layout = ({ s, p, f, now, width, height }) => {
const events = s.calendar.events;
const first = startOfWeek(now, s.weekStart);
const days = Array.from({ length: 7 }, (_, i) => addDays(first, i));
const colWidth = (width - 2 * FRAME - 2 * WEEK_PAD) / 7;
const colWidth = (width - 2 * WEEK_PAD) / 7;
const segments = allDaySegments(events, days);
const strip = segments.some((seg) => seg.event !== null);
const gridHeight = Math.max(
0,
height - 2 * FRAME - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
height - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
- (strip ? WEEK_STRIP_HEIGHT + WEEK_GAP : 0) - 1,
);
const windowStart = startOfDay(now) + GRID_START_HOUR * HOUR;
@@ -566,11 +564,11 @@ export const MonthLayout: Layout = ({ s, p, f, now, width, height }) => {
const weeks = monthGrid(s.calendar.events, now, s.weekStart);
const next = upcoming(s.calendar.events, now);
const headerHeight = height >= 340 ? 44 : 38;
const cellWidth = (width - 2 * FRAME - 2 * MONTH_PAD) / 7;
const cellWidth = (width - 2 * MONTH_PAD) / 7;
const chipsHeight = (n: number) =>
n === 0 ? 0 : MONTH_CHIPS_TOP + n * MONTH_CHIP_HEIGHT + (n - 1) * 4 + MONTH_CHIPS_BOTTOM;
const gridRoom = (n: number) =>
height - 2 * FRAME - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
height - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
// Two chips when the weeks still get a comfortable row, else one, else none.
let chips = Math.min(2, next.length);
while (chips > 0 && gridRoom(chips) / weeks.length < MONTH_CELL_MIN) chips -= 1;
@@ -700,7 +698,7 @@ function TodayColumn({
const tomorrowList = eventsOnDay(events, addDays(today, 1));
// The "N more this week" line is always reserved; chips share the rest.
const room = height - 2 * FRAME - 2 * TODAY_PAD - TODAY_LINE;
const room = height - 2 * TODAY_PAD - TODAY_LINE;
const row = TODAY_CHIP + TODAY_GAP;
let withTomorrow = tomorrowList.length > 0;
let slots = Math.floor((room - (withTomorrow ? TOMORROW_BLOCK : 0) + TODAY_GAP) / row);
@@ -792,7 +790,7 @@ function TodayColumn({
export const TodayEventsLayout: Layout = ({ s, p, f, now, height }) => {
// The date block is ~108dp tall at 40sp; a short 4x2 gets a smaller number
// and, if still tight, loses the month line.
const room = height - 2 * FRAME - 2 * TODAY_PAD;
const room = height - 2 * TODAY_PAD;
const big = room >= 108 ? 40 : 32;
const showMonth = room >= 3 * 18 + Math.ceil(big * 1.33);
return (
@@ -820,7 +818,7 @@ export const NextEventLayout: Layout = ({ s, p, f, now, height }) => {
const when = running ? f.t('widgets.calendar.now', 'Now') : capitalize(f.relative(e.start, now), f.locale);
// Status line, gap, one title line and the time range always show; then the
// place, the calendar and a second title line, as long as they fit.
const room = height - 2 * FRAME - 28;
const room = height - 28;
let used = 16 + 6 + 22 + 18;
const place = placeOf(f, e);
const showPlace = !!place && used + 16 <= room;
@@ -888,7 +886,7 @@ function TwoLineChip({ f, e, now }: { f: Fmt; e: EventItem; now: number }) {
}
export const DateNextLayout: Layout = ({ s, p, f, now, height }) => {
const room = height - 2 * FRAME - 2 * DATE_PAD - DATE_CIRCLE - 8;
const room = height - 2 * DATE_PAD - DATE_CIRCLE - 8;
const count = room >= 2 * DATE_CHIP + 4 ? 2 : 1;
const next = s.calendar.supported ? upcoming(s.calendar.events, now).slice(0, count) : [];
const chips: React.JSX.Element[] = [];
+2 -2
View File
@@ -306,8 +306,8 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
height: 'match_parent',
padding: pad,
backgroundColor: unread > 0 ? p.unreadRow : p.bg,
borderTopLeftRadius: WIDGET_RADIUS - 1,
borderBottomLeftRadius: WIDGET_RADIUS - 1,
borderTopLeftRadius: WIDGET_RADIUS,
borderBottomLeftRadius: WIDGET_RADIUS,
}}
>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
+6 -3
View File
@@ -17,9 +17,14 @@ import { chipBackground, normalizeHex, type WidgetPalette } from './theme';
import { filledIcon, icon, type FilledIconName, type IconName } from './icons';
import type { WidgetClick } from './clicks';
/** Corner radius of the widget surface; launchers clip to roughly this on Android 12+. */
/** Corner radius of the widget surface; `widget_radius` in android/app/src/main/res/values/dimens.xml. */
export const WIDGET_RADIUS = 22;
/**
* The widget's background. Its edge and the clip to rounded corners come from
* the root layout (android/app/src/main/res/layout/rn_widget.xml), which has the
* launcher's real size; this bitmap is drawn at the reported one and can be cut off.
*/
export function Surface({
p,
children,
@@ -39,8 +44,6 @@ export function Surface({
width: 'match_parent',
backgroundColor: p.bg,
borderRadius: WIDGET_RADIUS,
borderWidth: 1,
borderColor: p.frame,
overflow: 'hidden',
...style,
}}
+4 -5
View File
@@ -10,15 +10,16 @@ export type WidgetScheme = 'light' | 'dark';
export interface WidgetPalette {
scheme: WidgetScheme;
/** Widget surface (webmail `--color-background`). */
/**
* Widget surface (webmail `--color-background`). The edge around it is drawn
* natively, as `widget_frame` in android/app/src/main/res/values/colors.xml.
*/
bg: ColorProp;
fg: ColorProp;
muted: ColorProp;
/** Search pill, segmented track, quota ring track (webmail `--color-muted`). */
mutedBg: ColorProp;
border: ColorProp;
/** Outer widget edge. */
frame: ColorProp;
primary: ColorProp;
primaryFg: ColorProp;
accent: ColorProp;
@@ -66,7 +67,6 @@ export const LIGHT: WidgetPalette = {
muted: '#64748b',
mutedBg: '#f1f5f9',
border: '#e2e8f0',
frame: '#e2e8f0',
primary: '#3b82f6',
primaryFg: '#ffffff',
accent: '#dbeafe',
@@ -108,7 +108,6 @@ export const DARK: WidgetPalette = {
muted: '#a3a3a3',
mutedBg: '#262626',
border: '#2d2d2d',
frame: 'rgba(128, 128, 128, 0.3)',
primary: '#fafafa',
primaryFg: '#171717',
accent: '#1e3a8a',