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.
This commit is contained in:
Linus Rath
2026-09-27 04:22:33 +02:00
parent db07acc37c
commit 59cfe50793
33 changed files with 1452 additions and 102 deletions
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "محاولات كثيرة جدًا",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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ů",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -474,8 +474,14 @@
"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 +490,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "تلاش‌های بیش از حد",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "יותר מדי ניסיונות",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "試行回数が多すぎます",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "시도 횟수가 너무 많아요",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "Хэт олон оролдлого",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "Слишком много попыток",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "Забагато спроб",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "嘗試次數過多",
+18 -1
View File
@@ -464,8 +464,14 @@
"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 +480,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": "尝试次数过多",
+77 -3
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { describeLoginError } from '../login-errors';
import { PairingError, type PairingErrorReason } from '../oauth';
function named(name: string, message: string): Error {
const err = new Error(message);
@@ -45,10 +46,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 +63,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');
});
});
+290
View File
@@ -0,0 +1,290 @@
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,
parsePastedSignInLink,
parseQrLoginPayload,
redeemPairingCode,
refreshOAuthAccessToken,
} 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('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('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',
});
});
});
+76 -4
View File
@@ -34,6 +34,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 +109,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 +208,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')) {
+220 -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,162 @@ export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
return null;
}
/**
* 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);
// 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 +478,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');
}
@@ -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/);
});
});
+69 -16
View File
@@ -13,7 +13,14 @@ 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 {
runWebmailHandoff,
redeemPairingCode,
HandoffCancelledError,
HandoffError,
PairingError,
type HandoffResult,
} from '../lib/oauth';
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
import {
teardownPushNotifications,
@@ -153,8 +160,18 @@ 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];
}
// 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);
@@ -428,34 +445,70 @@ 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),
});
}
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' || /maximum of \d+ accounts/i.test(err.message))) {
// `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);
}
},