From 59cfe50793d8d8d38a1126670e794f2cdcfcd055 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:22:33 +0200 Subject: [PATCH] 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. --- locales/rn/ar.json | 19 +- locales/rn/ca.json | 19 +- locales/rn/cs.json | 19 +- locales/rn/da.json | 19 +- locales/rn/de.json | 19 +- locales/rn/en.json | 19 +- locales/rn/es.json | 19 +- locales/rn/fa.json | 19 +- locales/rn/fr.json | 19 +- locales/rn/he.json | 19 +- locales/rn/hu.json | 19 +- locales/rn/it.json | 19 +- locales/rn/ja.json | 19 +- locales/rn/ko.json | 19 +- locales/rn/lv.json | 19 +- locales/rn/mn.json | 19 +- locales/rn/nb.json | 19 +- locales/rn/nl.json | 19 +- locales/rn/pl.json | 19 +- locales/rn/pt.json | 19 +- locales/rn/ro.json | 19 +- locales/rn/ru.json | 19 +- locales/rn/sk.json | 19 +- locales/rn/tr.json | 19 +- locales/rn/uk.json | 19 +- locales/rn/zh-TW.json | 19 +- locales/rn/zh.json | 19 +- src/lib/__tests__/login-errors.test.ts | 80 ++++- src/lib/__tests__/oauth-pairing.test.ts | 290 ++++++++++++++++++ src/lib/login-errors.ts | 80 ++++- src/lib/oauth.ts | 272 ++++++++++++---- .../__tests__/auth-store-pairing.test.ts | 234 ++++++++++++++ src/stores/auth-store.ts | 85 ++++- 33 files changed, 1452 insertions(+), 102 deletions(-) create mode 100644 src/lib/__tests__/oauth-pairing.test.ts create mode 100644 src/stores/__tests__/auth-store-pairing.test.ts diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 502ef925..4f59a281 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -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": "محاولات كثيرة جدًا", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 1f3d0c8e..f3bf3bdb 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -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", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 9c527427..6bcc16f2 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -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ů", diff --git a/locales/rn/da.json b/locales/rn/da.json index e80afe4d..cbe31681 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -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", diff --git a/locales/rn/de.json b/locales/rn/de.json index 67f7861e..6cc5810d 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -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", diff --git a/locales/rn/en.json b/locales/rn/en.json index b6c558d8..e12221de 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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", diff --git a/locales/rn/es.json b/locales/rn/es.json index b601d8a3..58f02755 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -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", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index e5afa8f0..e37d6372 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -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": "تلاش‌های بیش از حد", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index a58dbf6d..0c5f7085 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -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", diff --git a/locales/rn/he.json b/locales/rn/he.json index e4ded47f..559b6a52 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -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": "יותר מדי ניסיונות", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 41a1e34e..59574448 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -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", diff --git a/locales/rn/it.json b/locales/rn/it.json index dd947280..0bfe780e 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -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", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 0b517a77..4aace21b 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -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": "試行回数が多すぎます", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index e6f26abf..fa6751cf 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -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": "시도 횟수가 너무 많아요", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index e5c449c7..75316359 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -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", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index eaf3f827..6236220d 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -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": "Хэт олон оролдлого", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 54042dde..5a3af394 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -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", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index c657a088..5aa8f1ec 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -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", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 317a88c0..b71889cc 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -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", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index bfd322e6..62f782f4 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -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", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 0123cafc..9e3ab184 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -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", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 62bd0b80..9d3bba8b 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -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": "Слишком много попыток", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index befaa934..673157d6 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -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", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index cd1c496e..9952ae90 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -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", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index a885fa36..a1861114 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -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": "Забагато спроб", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index c8d7e800..6028536a 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -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": "嘗試次數過多", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 6bb8f899..0997bec6 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -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": "尝试次数过多", diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts index d455b081..412576b6 100644 --- a/src/lib/__tests__/login-errors.test.ts +++ b/src/lib/__tests__/login-errors.test.ts @@ -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) => + `${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'); + }); +}); diff --git a/src/lib/__tests__/oauth-pairing.test.ts b/src/lib/__tests__/oauth-pairing.test.ts new file mode 100644 index 00000000..5f89b175 --- /dev/null +++ b/src/lib/__tests__/oauth-pairing.test.ts @@ -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('Not here', { + status, + headers: { 'Content-Type': 'text/html' }, + }); +} + +async function redeemFailure(response: Response | Error): Promise { + 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', + }); + }); +}); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index 70646643..7f186ba3 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -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')) { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index ccf0c92e..745737ad 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -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 | null> { + try { + const data: unknown = await response.json(); + return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record) : null; + } catch { + return null; + } +} + +function stringField(data: Record, 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 | 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 { 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 | undefined; + const deadline = new Promise((_, 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 | 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 + // (`/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>(); @@ -323,11 +478,24 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise, 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]>) => 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()), + 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; +const connect = jmapClient.connect as unknown as ReturnType; +const connectWithOAuth = jmapClient.connectWithOAuth as unknown as ReturnType; + +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((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/); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 67bf018a..767374b2 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -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(); + +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 { try { const entry = useAccountStore.getState().getAccountById(accountId); @@ -428,34 +445,70 @@ export const useAuthStore = create((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); } },