mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
+18
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "尝试次数过多",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user