diff --git a/App.tsx b/App.tsx index 28c60920..f8d60275 100644 --- a/App.tsx +++ b/App.tsx @@ -73,8 +73,16 @@ import { AppIconBadge } from './src/components/AppIconBadge'; import { getEmails } from './src/api/email'; import { signOutWidgets, startWidgetSync } from './src/widgets/sync'; import { draftContextFromEmail } from './src/lib/draft-context'; -import { acceptSignInLink, handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; +import { + acceptSignInLink, + handleDeepLink, + parseDeepLink, + routeParkedSignInLink, + shareToDeepLink, + type DeepLink, +} from './src/navigation/linking'; import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link'; +import { MAX_ACCOUNTS } from './src/lib/account-utils'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; import { useOfflineCacheStore } from './src/stores/offline-cache-store'; @@ -573,8 +581,10 @@ export default function App() { // Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App, // `bulwarkmail://connect?…`) work signed out too, so unlike the links above - // they are listened for from launch. They are parked for the login screen: - // the signed-out one takes them as it mounts. + // they are listened for from launch. They are parked for the login screen + // (the signed-out one takes them as it mounts), marked as coming from + // outside the app: any page or app can fire one, so the login screen asks + // before it redeems or opens anything. React.useEffect(() => { if (!initialSignInLinkRead) { initialSignInLinkRead = true; @@ -585,9 +595,10 @@ export default function App() { }, []); // Signed in, a sign-in link adds an account: open Add account, whose login - // screen takes the parked link. On a cold start the navigator mounts right - // after the auth gate flips, so wait for it like a reminder tap does. - const signInLinkPending = usePendingSignInLinkStore((s) => s.payload !== null); + // screen takes the parked link (or says why a refused one won't be used). + // On a cold start the navigator mounts right after the auth gate flips, so + // wait for it like a reminder tap does. + const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null); const addAccountForLink = isAuthenticated && signInLinkPending; React.useEffect(() => { if (!addAccountForLink) return; @@ -598,9 +609,18 @@ export default function App() { if (cancelled) return; } if (cancelled || !navigationRef.isReady()) return; - // Taken meanwhile (Add account was already open). - if (!usePendingSignInLinkStore.getState().payload) return; - navigationRef.navigate('AddAccount'); + const route = routeParkedSignInLink(useAccountStore.getState().accounts.length); + if (route === 'account-limit') { + // Said like the Add account buttons say it, instead of opening a + // screen whose sign-in can only fail. + const t = useLocaleStore.getState().t; + toast.error( + t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }), + t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'), + ); + return; + } + if (route === 'add-account') navigationRef.navigate('AddAccount'); })(); return () => { cancelled = true; }; }, [addAccountForLink]); diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 6c1d9564..baa642b5 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -505,6 +505,12 @@ "know_server": "أعرف عنوان خادمي", "known_server_desc": "نعرف هذا الخادم بالفعل، لذا لا حاجة إلى إعداد", "known_server_title": "حساب آخر على {host}", + "link_confirm_connect": "يطلب رابط تسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.", + "link_confirm_connect_add": "يطلب رابط إضافة حساب بتسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.", + "link_confirm_connect_title": "فتح صفحة تسجيل الدخول في {host}؟", + "link_confirm_pair": "يسجّل هذا الرابط الدخول إلى حساب من {host}. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.", + "link_confirm_pair_add": "يضيف هذا الرابط حسابًا من {host} وينقل التطبيق إليه. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.", + "link_confirm_title": "تسجيل الدخول برابط؟", "looking_up": "جارٍ البحث عن خادمك…", "manual": "إدخال تفاصيل الخادم يدويًا", "notice_bad_qr": "هذا الرمز ليس رمز تسجيل دخول Bulwark", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index f081bbff..3d08d711 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -505,6 +505,12 @@ "know_server": "Conec l'adreça del meu servidor", "known_server_desc": "Ja coneixem aquest servidor, així que no cal configurar res", "known_server_title": "Un altre compte a {host}", + "link_confirm_connect": "Un enllaç demana iniciar la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.", + "link_confirm_connect_add": "Un enllaç demana afegir un compte iniciant la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.", + "link_confirm_connect_title": "Voleu obrir la pàgina d'inici de sessió de {host}?", + "link_confirm_pair": "Aquest enllaç inicia la sessió en un compte de {host}. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.", + "link_confirm_pair_add": "Aquest enllaç afegeix un compte de {host} i hi canvia l'aplicació. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.", + "link_confirm_title": "Voleu iniciar la sessió amb un enllaç?", "looking_up": "S'està cercant el vostre servidor…", "manual": "Introdueix les dades del servidor manualment", "notice_bad_qr": "Aquest codi no és un codi d'inici de sessió del Bulwark", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index f05efcf4..6b719370 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -505,6 +505,12 @@ "know_server": "Znám adresu svého serveru", "known_server_desc": "Tento server už známe, takže není třeba nic nastavovat", "known_server_title": "Další účet na {host}", + "link_confirm_connect": "Odkaz žádá o přihlášení na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.", + "link_confirm_connect_add": "Odkaz žádá o přidání účtu přihlášením na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.", + "link_confirm_connect_title": "Otevřít přihlašovací stránku {host}?", + "link_confirm_pair": "Tento odkaz vás přihlásí k účtu z {host}. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.", + "link_confirm_pair_add": "Tento odkaz přidá účet z {host} a přepne na něj aplikaci. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.", + "link_confirm_title": "Přihlásit se pomocí odkazu?", "looking_up": "Hledání vašeho serveru…", "manual": "Zadat údaje serveru ručně", "notice_bad_qr": "Toto není přihlašovací kód Bulwark", diff --git a/locales/rn/da.json b/locales/rn/da.json index f61a818f..fdd4c96d 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -505,6 +505,12 @@ "know_server": "Jeg kender min serveradresse", "known_server_desc": "Vi kender allerede denne server, så der skal ikke konfigureres noget", "known_server_title": "En anden konto på {host}", + "link_confirm_connect": "Et link beder om at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.", + "link_confirm_connect_add": "Et link beder om at tilføje en konto ved at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.", + "link_confirm_connect_title": "Åbn login-siden for {host}?", + "link_confirm_pair": "Dette link logger ind på en konto fra {host}. Fortsæt kun, hvis du lige har vist en loginkode på din computer.", + "link_confirm_pair_add": "Dette link tilføjer en konto fra {host} og skifter appen til den. Fortsæt kun, hvis du lige har vist en loginkode på din computer.", + "link_confirm_title": "Log ind med et link?", "looking_up": "Slår din server op…", "manual": "Indtast serveroplysninger manuelt", "notice_bad_qr": "Den kode er ikke en Bulwark-loginkode", diff --git a/locales/rn/de.json b/locales/rn/de.json index 3610618f..542eb303 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -505,6 +505,12 @@ "know_server": "Ich kenne meine Serveradresse", "known_server_desc": "Diesen Server kennen wir bereits, daher ist keine Einrichtung nötig", "known_server_title": "Weiteres Konto auf {host}", + "link_confirm_connect": "Ein Link möchte Sie auf dieser Seite anmelden. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.", + "link_confirm_connect_add": "Ein Link möchte über die Anmeldung auf dieser Seite ein Konto hinzufügen. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.", + "link_confirm_connect_title": "Anmeldeseite von {host} öffnen?", + "link_confirm_pair": "Dieser Link meldet Sie bei einem Konto von {host} an. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.", + "link_confirm_pair_add": "Dieser Link fügt ein Konto von {host} hinzu und wechselt die App zu diesem Konto. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.", + "link_confirm_title": "Mit einem Link anmelden?", "looking_up": "Ihr Server wird gesucht…", "manual": "Serverdaten manuell eingeben", "notice_bad_qr": "Das ist kein Bulwark-Anmeldecode", diff --git a/locales/rn/en.json b/locales/rn/en.json index c1e98852..460f309d 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -515,6 +515,12 @@ "know_server": "I know my server address", "known_server_desc": "We already know this server, so there's no setup", "known_server_title": "Another account on {host}", + "link_confirm_connect": "A link asks to sign in on this page. Only continue if you use {host} for your mail.", + "link_confirm_connect_add": "A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.", + "link_confirm_connect_title": "Open the sign-in page of {host}?", + "link_confirm_pair": "This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.", + "link_confirm_pair_add": "This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.", + "link_confirm_title": "Sign in with a link?", "looking_up": "Looking up your server…", "manual": "Enter server details manually", "notice_bad_qr": "That code isn't a Bulwark sign-in code", diff --git a/locales/rn/es.json b/locales/rn/es.json index a0ee968e..296b91ea 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -505,6 +505,12 @@ "know_server": "Conozco la dirección de mi servidor", "known_server_desc": "Ya conocemos este servidor, así que no hay nada que configurar", "known_server_title": "Otra cuenta en {host}", + "link_confirm_connect": "Un enlace pide iniciar sesión en esta página. Continúa solo si usas {host} para tu correo.", + "link_confirm_connect_add": "Un enlace pide añadir una cuenta iniciando sesión en esta página. Continúa solo si usas {host} para tu correo.", + "link_confirm_connect_title": "¿Abrir la página de inicio de sesión de {host}?", + "link_confirm_pair": "Este enlace inicia sesión en una cuenta de {host}. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.", + "link_confirm_pair_add": "Este enlace añade una cuenta de {host} y cambia la aplicación a ella. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.", + "link_confirm_title": "¿Iniciar sesión con un enlace?", "looking_up": "Buscando tu servidor…", "manual": "Introducir los datos del servidor manualmente", "notice_bad_qr": "Ese código no es un código de inicio de sesión de Bulwark", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 8b82e9b6..5c63c316 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -505,6 +505,12 @@ "know_server": "نشانی سرورم را می‌دانم", "known_server_desc": "این سرور را از قبل می‌شناسیم، پس نیازی به راه‌اندازی نیست", "known_server_title": "حسابی دیگر در {host}", + "link_confirm_connect": "یک پیوند می‌خواهد در این صفحه وارد شوید. فقط اگر برای ایمیل‌تان از {host} استفاده می‌کنید ادامه دهید.", + "link_confirm_connect_add": "یک پیوند می‌خواهد با ورود در این صفحه حسابی اضافه کنید. فقط اگر برای ایمیل‌تان از {host} استفاده می‌کنید ادامه دهید.", + "link_confirm_connect_title": "صفحه ورود {host} باز شود؟", + "link_confirm_pair": "این پیوند شما را به حسابی از {host} وارد می‌کند. فقط اگر همین حالا روی رایانه‌تان کد ورود نمایش داده‌اید ادامه دهید.", + "link_confirm_pair_add": "این پیوند حسابی از {host} را اضافه می‌کند و برنامه را به آن حساب می‌برد. فقط اگر همین حالا روی رایانه‌تان کد ورود نمایش داده‌اید ادامه دهید.", + "link_confirm_title": "با پیوند وارد می‌شوید؟", "looking_up": "در حال جستجوی سرور شما…", "manual": "وارد کردن دستی مشخصات سرور", "notice_bad_qr": "این کد، کد ورود Bulwark نیست", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 99d821bf..63a547b9 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -505,6 +505,12 @@ "know_server": "Je connais l'adresse de mon serveur", "known_server_desc": "Nous connaissons déjà ce serveur, aucune configuration n'est donc nécessaire", "known_server_title": "Un autre compte sur {host}", + "link_confirm_connect": "Un lien demande à vous connecter sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.", + "link_confirm_connect_add": "Un lien demande à ajouter un compte en vous connectant sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.", + "link_confirm_connect_title": "Ouvrir la page de connexion de {host} ?", + "link_confirm_pair": "Ce lien vous connecte à un compte de {host}. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.", + "link_confirm_pair_add": "Ce lien ajoute un compte de {host} et fait basculer l'application sur ce compte. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.", + "link_confirm_title": "Se connecter avec un lien ?", "looking_up": "Recherche de votre serveur…", "manual": "Saisir les informations du serveur manuellement", "notice_bad_qr": "Ce code n'est pas un code de connexion Bulwark", diff --git a/locales/rn/he.json b/locales/rn/he.json index a8aac6f6..127b80a6 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -505,6 +505,12 @@ "know_server": "אני יודע את כתובת השרת שלי", "known_server_desc": "השרת הזה כבר מוכר לנו, כך שאין צורך בהגדרה", "known_server_title": "חשבון נוסף ב-{host}", + "link_confirm_connect": "קישור מבקש להתחבר בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.", + "link_confirm_connect_add": "קישור מבקש להוסיף חשבון באמצעות התחברות בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.", + "link_confirm_connect_title": "לפתוח את דף ההתחברות של {host}?", + "link_confirm_pair": "הקישור הזה מחבר לחשבון מ-{host}. המשך רק אם הצגת עכשיו קוד התחברות במחשב.", + "link_confirm_pair_add": "הקישור הזה מוסיף חשבון מ-{host} ומעביר אליו את היישום. המשך רק אם הצגת עכשיו קוד התחברות במחשב.", + "link_confirm_title": "להתחבר עם קישור?", "looking_up": "מחפש את השרת שלך…", "manual": "הזן את פרטי השרת ידנית", "notice_bad_qr": "הקוד הזה אינו קוד התחברות של Bulwark", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 5cd2e5b7..6affa044 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -505,6 +505,12 @@ "know_server": "Ismerem a szerverem címét", "known_server_desc": "Ezt a szervert már ismerjük, így nincs mit beállítani", "known_server_title": "Másik fiók itt: {host}", + "link_confirm_connect": "Egy hivatkozás bejelentkezést kér ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.", + "link_confirm_connect_add": "Egy hivatkozás egy fiók hozzáadását kéri bejelentkezéssel ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.", + "link_confirm_connect_title": "Megnyitod a(z) {host} bejelentkezési oldalát?", + "link_confirm_pair": "Ez a hivatkozás bejelentkeztet egy fiókba itt: {host}. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.", + "link_confirm_pair_add": "Ez a hivatkozás hozzáad egy fiókot innen: {host}, és átváltja rá az alkalmazást. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.", + "link_confirm_title": "Bejelentkezel egy hivatkozással?", "looking_up": "Szerver keresése…", "manual": "Szerveradatok kézi megadása", "notice_bad_qr": "Ez nem Bulwark bejelentkezési kód", diff --git a/locales/rn/it.json b/locales/rn/it.json index 6d9ff388..14b57121 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -505,6 +505,12 @@ "know_server": "Conosco l'indirizzo del mio server", "known_server_desc": "Conosciamo già questo server, quindi non c'è nulla da configurare", "known_server_title": "Un altro account su {host}", + "link_confirm_connect": "Un link chiede di accedere su questa pagina. Continua solo se usi {host} per la tua posta.", + "link_confirm_connect_add": "Un link chiede di aggiungere un account accedendo su questa pagina. Continua solo se usi {host} per la tua posta.", + "link_confirm_connect_title": "Aprire la pagina di accesso di {host}?", + "link_confirm_pair": "Questo link ti fa accedere a un account di {host}. Continua solo se hai appena mostrato un codice di accesso sul computer.", + "link_confirm_pair_add": "Questo link aggiunge un account di {host} e passa l'app a quell'account. Continua solo se hai appena mostrato un codice di accesso sul computer.", + "link_confirm_title": "Accedere con un link?", "looking_up": "Ricerca del tuo server…", "manual": "Inserisci i dati del server manualmente", "notice_bad_qr": "Questo codice non è un codice di accesso Bulwark", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 14374f3c..69ef4a74 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -505,6 +505,12 @@ "know_server": "サーバーアドレスがわかる", "known_server_desc": "このサーバーは登録済みのため、設定は不要です", "known_server_title": "{host}の別のアカウント", + "link_confirm_connect": "リンクがこのページでのサインインを求めています。メールに {host} を使っている場合にのみ続行してください。", + "link_confirm_connect_add": "リンクがこのページでサインインしてアカウントを追加するよう求めています。メールに {host} を使っている場合にのみ続行してください。", + "link_confirm_connect_title": "{host} のサインインページを開きますか?", + "link_confirm_pair": "このリンクは {host} のアカウントにサインインします。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。", + "link_confirm_pair_add": "このリンクは {host} のアカウントを追加し、アプリをそのアカウントに切り替えます。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。", + "link_confirm_title": "リンクでサインインしますか?", "looking_up": "サーバーを検索しています…", "manual": "サーバー情報を手動で入力", "notice_bad_qr": "このコードは Bulwark のサインインコードではありません", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 581e9516..025365af 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -505,6 +505,12 @@ "know_server": "서버 주소를 알고 있어요", "known_server_desc": "이미 알고 있는 서버라서 설정이 필요 없어요", "known_server_title": "{host}의 다른 계정", + "link_confirm_connect": "링크가 이 페이지에서 로그인하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.", + "link_confirm_connect_add": "링크가 이 페이지에서 로그인해 계정을 추가하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.", + "link_confirm_connect_title": "{host}의 로그인 페이지를 열까요?", + "link_confirm_pair": "이 링크는 {host}의 계정으로 로그인해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.", + "link_confirm_pair_add": "이 링크는 {host}의 계정을 추가하고 앱을 해당 계정으로 전환해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.", + "link_confirm_title": "링크로 로그인할까요?", "looking_up": "서버를 찾는 중…", "manual": "서버 정보 직접 입력", "notice_bad_qr": "Bulwark 로그인 코드가 아니에요", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index a129314a..016b11ec 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -505,6 +505,12 @@ "know_server": "Es zinu sava servera adresi", "known_server_desc": "Šis serveris mums jau ir zināms, tāpēc nekas nav jāiestata", "known_server_title": "Vēl viens konts: {host}", + "link_confirm_connect": "Saite aicina pierakstīties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.", + "link_confirm_connect_add": "Saite aicina pievienot kontu, pierakstoties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.", + "link_confirm_connect_title": "Atvērt {host} pierakstīšanās lapu?", + "link_confirm_pair": "Šī saite pierakstās kontā no {host}. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.", + "link_confirm_pair_add": "Šī saite pievieno kontu no {host} un pārslēdz lietotni uz to. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.", + "link_confirm_title": "Pierakstīties ar saiti?", "looking_up": "Meklē jūsu serveri…", "manual": "Ievadīt servera datus manuāli", "notice_bad_qr": "Šis nav Bulwark pierakstīšanās kods", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 9762e509..2a1f8ec4 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -505,6 +505,12 @@ "know_server": "Би серверийн хаягаа мэднэ", "known_server_desc": "Бид энэ серверийг аль хэдийн мэддэг тул тохируулах шаардлагагүй", "known_server_title": "{host} дээрх өөр бүртгэл", + "link_confirm_connect": "Холбоос энэ хуудсанд нэвтрэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.", + "link_confirm_connect_add": "Холбоос энэ хуудсанд нэвтэрч бүртгэл нэмэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.", + "link_confirm_connect_title": "{host}-ийн нэвтрэх хуудсыг нээх үү?", + "link_confirm_pair": "Энэ холбоос {host} дээрх бүртгэлд нэвтрүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.", + "link_confirm_pair_add": "Энэ холбоос {host} дээрх бүртгэлийг нэмээд програмыг түүн рүү шилжүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.", + "link_confirm_title": "Холбоосоор нэвтрэх үү?", "looking_up": "Таны серверийг хайж байна…", "manual": "Серверийн мэдээллийг гараар оруулах", "notice_bad_qr": "Энэ код Bulwark-ийн нэвтрэх код биш байна", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index a9ea2598..5f05000d 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -505,6 +505,12 @@ "know_server": "Jeg kjenner serveradressen min", "known_server_desc": "Vi kjenner allerede denne serveren, så du trenger ikke å sette opp noe", "known_server_title": "En annen konto på {host}", + "link_confirm_connect": "En lenke ber om å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.", + "link_confirm_connect_add": "En lenke ber om å legge til en konto ved å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.", + "link_confirm_connect_title": "Åpne påloggingssiden til {host}?", + "link_confirm_pair": "Denne lenken logger på en konto fra {host}. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.", + "link_confirm_pair_add": "Denne lenken legger til en konto fra {host} og bytter appen til den. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.", + "link_confirm_title": "Logge på med en lenke?", "looking_up": "Slår opp serveren din…", "manual": "Skriv inn serverdetaljer manuelt", "notice_bad_qr": "Koden er ikke en påloggingskode for Bulwark", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 2b7d732d..567917eb 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -505,6 +505,12 @@ "know_server": "Ik ken mijn serveradres", "known_server_desc": "We kennen deze server al, dus er hoeft niets te worden ingesteld", "known_server_title": "Nog een account op {host}", + "link_confirm_connect": "Een link vraagt om op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.", + "link_confirm_connect_add": "Een link vraagt om een account toe te voegen door op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.", + "link_confirm_connect_title": "Inlogpagina van {host} openen?", + "link_confirm_pair": "Deze link logt u in op een account van {host}. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.", + "link_confirm_pair_add": "Deze link voegt een account van {host} toe en schakelt de app daarnaar over. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.", + "link_confirm_title": "Inloggen met een link?", "looking_up": "Uw server opzoeken…", "manual": "Servergegevens handmatig invoeren", "notice_bad_qr": "Die code is geen Bulwark-inlogcode", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 43fa5e4f..e077fdca 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -505,6 +505,12 @@ "know_server": "Znam adres swojego serwera", "known_server_desc": "Ten serwer jest już znany, więc nie trzeba niczego konfigurować", "known_server_title": "Kolejne konto na {host}", + "link_confirm_connect": "Link prosi o zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.", + "link_confirm_connect_add": "Link prosi o dodanie konta przez zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.", + "link_confirm_connect_title": "Otworzyć stronę logowania {host}?", + "link_confirm_pair": "Ten link loguje do konta z {host}. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.", + "link_confirm_pair_add": "Ten link dodaje konto z {host} i przełącza na nie aplikację. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.", + "link_confirm_title": "Zalogować się za pomocą linku?", "looking_up": "Wyszukiwanie serwera…", "manual": "Wprowadź dane serwera ręcznie", "notice_bad_qr": "To nie jest kod logowania Bulwark", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 67b05864..14bbf396 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -505,6 +505,12 @@ "know_server": "Sei o endereço do meu servidor", "known_server_desc": "Já conhecemos este servidor, então não há nada para configurar", "known_server_title": "Outra conta em {host}", + "link_confirm_connect": "Um link pede para fazer login nesta página. Só continue se você usa {host} para o seu e-mail.", + "link_confirm_connect_add": "Um link pede para adicionar uma conta fazendo login nesta página. Só continue se você usa {host} para o seu e-mail.", + "link_confirm_connect_title": "Abrir a página de login de {host}?", + "link_confirm_pair": "Este link faz login em uma conta de {host}. Só continue se você acabou de mostrar um código de login no computador.", + "link_confirm_pair_add": "Este link adiciona uma conta de {host} e muda o aplicativo para ela. Só continue se você acabou de mostrar um código de login no computador.", + "link_confirm_title": "Entrar com um link?", "looking_up": "Procurando seu servidor…", "manual": "Inserir os dados do servidor manualmente", "notice_bad_qr": "Esse código não é um código de login do Bulwark", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 42372486..2444166b 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -505,6 +505,12 @@ "know_server": "Cunosc adresa serverului meu", "known_server_desc": "Cunoaștem deja acest server, deci nu este nimic de configurat", "known_server_title": "Alt cont pe {host}", + "link_confirm_connect": "Un link cere autentificarea pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.", + "link_confirm_connect_add": "Un link cere adăugarea unui cont prin autentificare pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.", + "link_confirm_connect_title": "Deschideți pagina de autentificare a {host}?", + "link_confirm_pair": "Acest link vă autentifică într-un cont de la {host}. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.", + "link_confirm_pair_add": "Acest link adaugă un cont de la {host} și comută aplicația pe el. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.", + "link_confirm_title": "Vă autentificați cu un link?", "looking_up": "Se caută serverul dvs.…", "manual": "Introduceți manual detaliile serverului", "notice_bad_qr": "Acesta nu este un cod de autentificare Bulwark", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 816c6d31..d958ba18 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -505,6 +505,12 @@ "know_server": "Я знаю адрес своего сервера", "known_server_desc": "Этот сервер уже известен, настройка не нужна", "known_server_title": "Ещё один аккаунт на {host}", + "link_confirm_connect": "Ссылка предлагает войти на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.", + "link_confirm_connect_add": "Ссылка предлагает добавить аккаунт, войдя на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.", + "link_confirm_connect_title": "Открыть страницу входа {host}?", + "link_confirm_pair": "Эта ссылка выполняет вход в аккаунт на {host}. Продолжайте, только если вы только что показали код входа на своём компьютере.", + "link_confirm_pair_add": "Эта ссылка добавляет аккаунт с {host} и переключает на него приложение. Продолжайте, только если вы только что показали код входа на своём компьютере.", + "link_confirm_title": "Войти по ссылке?", "looking_up": "Ищем ваш сервер…", "manual": "Ввести данные сервера вручную", "notice_bad_qr": "Это не код входа Bulwark", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 98b8a709..3bef1695 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -505,6 +505,12 @@ "know_server": "Poznám adresu svojho servera", "known_server_desc": "Tento server už poznáme, takže nie je potrebné nič nastavovať", "known_server_title": "Ďalší účet na {host}", + "link_confirm_connect": "Odkaz žiada o prihlásenie na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.", + "link_confirm_connect_add": "Odkaz žiada o pridanie účtu prihlásením na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.", + "link_confirm_connect_title": "Otvoriť prihlasovaciu stránku {host}?", + "link_confirm_pair": "Tento odkaz vás prihlási do účtu z {host}. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.", + "link_confirm_pair_add": "Tento odkaz pridá účet z {host} a prepne naň aplikáciu. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.", + "link_confirm_title": "Prihlásiť sa pomocou odkazu?", "looking_up": "Hľadá sa váš server…", "manual": "Zadať údaje servera ručne", "notice_bad_qr": "Toto nie je prihlasovací kód Bulwark", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 07934f97..497bd659 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -505,6 +505,12 @@ "know_server": "Sunucu adresimi biliyorum", "known_server_desc": "Bu sunucuyu zaten tanıyoruz, kurulum gerekmiyor", "known_server_title": "{host} üzerinde başka bir hesap", + "link_confirm_connect": "Bir bağlantı bu sayfada giriş yapmanızı istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.", + "link_confirm_connect_add": "Bir bağlantı bu sayfada giriş yaparak hesap eklemenizi istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.", + "link_confirm_connect_title": "{host} giriş sayfası açılsın mı?", + "link_confirm_pair": "Bu bağlantı {host} üzerindeki bir hesapta oturum açar. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.", + "link_confirm_pair_add": "Bu bağlantı {host} üzerindeki bir hesabı ekler ve uygulamayı bu hesaba geçirir. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.", + "link_confirm_title": "Bağlantıyla giriş yapılsın mı?", "looking_up": "Sunucunuz aranıyor…", "manual": "Sunucu bilgilerini elle girin", "notice_bad_qr": "Bu bir Bulwark giriş kodu değil", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 3aac14cf..3f70488f 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -505,6 +505,12 @@ "know_server": "Я знаю адресу свого сервера", "known_server_desc": "Цей сервер уже відомий, налаштування не потрібне", "known_server_title": "Ще один обліковий запис на {host}", + "link_confirm_connect": "Посилання пропонує увійти на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.", + "link_confirm_connect_add": "Посилання пропонує додати обліковий запис, увійшовши на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.", + "link_confirm_connect_title": "Відкрити сторінку входу {host}?", + "link_confirm_pair": "Це посилання виконує вхід в обліковий запис на {host}. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.", + "link_confirm_pair_add": "Це посилання додає обліковий запис з {host} і перемикає на нього застосунок. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.", + "link_confirm_title": "Увійти за посиланням?", "looking_up": "Шукаємо ваш сервер…", "manual": "Ввести дані сервера вручну", "notice_bad_qr": "Це не код входу Bulwark", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 0e0273e5..89ea4dc7 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -505,6 +505,12 @@ "know_server": "我知道伺服器地址", "known_server_desc": "我們已知道此伺服器,不需要設定", "known_server_title": "{host} 上的其他帳號", + "link_confirm_connect": "有連結要求在此頁面登入。只有在您的郵件使用 {host} 時才繼續。", + "link_confirm_connect_add": "有連結要求透過在此頁面登入來新增帳號。只有在您的郵件使用 {host} 時才繼續。", + "link_confirm_connect_title": "要開啟 {host} 的登入頁面嗎?", + "link_confirm_pair": "此連結會登入 {host} 上的帳號。只有在您剛在電腦上顯示登入碼時才繼續。", + "link_confirm_pair_add": "此連結會新增 {host} 上的帳號,並將應用程式切換到該帳號。只有在您剛在電腦上顯示登入碼時才繼續。", + "link_confirm_title": "要使用連結登入嗎?", "looking_up": "正在尋找您的伺服器…", "manual": "手動輸入伺服器資訊", "notice_bad_qr": "此代碼不是 Bulwark 登入碼", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index ed7bd8af..f07891c2 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -505,6 +505,12 @@ "know_server": "我知道服务器地址", "known_server_desc": "我们已经知道此服务器,无需设置", "known_server_title": "{host} 上的其他账户", + "link_confirm_connect": "有链接请求在此页面登录。仅当您的邮箱使用 {host} 时才继续。", + "link_confirm_connect_add": "有链接请求通过在此页面登录来添加账户。仅当您的邮箱使用 {host} 时才继续。", + "link_confirm_connect_title": "打开 {host} 的登录页面?", + "link_confirm_pair": "此链接将登录 {host} 上的账户。仅当您刚刚在电脑上显示了登录码时才继续。", + "link_confirm_pair_add": "此链接将添加 {host} 上的账户,并将应用切换到该账户。仅当您刚刚在电脑上显示了登录码时才继续。", + "link_confirm_title": "使用链接登录?", "looking_up": "正在查找您的服务器…", "manual": "手动输入服务器信息", "notice_bad_qr": "该代码不是 Bulwark 登录码", diff --git a/src/components/PasteSignInLinkModal.tsx b/src/components/PasteSignInLinkModal.tsx index 403fd964..b962f585 100644 --- a/src/components/PasteSignInLinkModal.tsx +++ b/src/components/PasteSignInLinkModal.tsx @@ -17,7 +17,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import Button from './Button'; import { useLocaleStore } from '../stores/locale-store'; -import { parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth'; +import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth'; +import { describeLoginError } from '../lib/login-errors'; interface PasteSignInLinkModalProps { visible: boolean; @@ -63,6 +64,13 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn const submit = React.useCallback(() => { const payload = parsePastedSignInLink(text); if (!payload) { + // A pairing link for a plain-http webmail: say why it won't be used. + const insecure = insecurePairingLinkError(text); + if (insecure) { + const copy = describeLoginError(insecure, { t }); + setError(copy.detail ?? copy.title); + return; + } setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link")); return; } diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts index c6ad607e..02aca1e2 100644 --- a/src/lib/__tests__/login-errors.test.ts +++ b/src/lib/__tests__/login-errors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { describeLoginError } from '../login-errors'; -import { PairingError, type PairingErrorReason } from '../oauth'; +import { PairingError, insecurePairingLinkError, type PairingErrorReason } from '../oauth'; import { AccountLimitError } from '../account-utils'; function named(name: string, message: string): Error { @@ -31,6 +31,14 @@ describe('describeLoginError', () => { expect(describeLoginError('Maximum of 10 accounts reached')).toEqual(want); }); + it('explains a scanned, pasted or tapped pairing link for a plain-http webmail', () => { + const link = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org/mail')}&code=${'a1'.repeat(32)}`; + expect(describeLoginError(insecurePairingLinkError(link))).toEqual({ + title: "This sign-in can't be trusted", + detail: 'webmail.example.org wants to use an unencrypted connection.', + }); + }); + it('names the host it could not reach', () => { const copy = describeLoginError(named('NetworkError', 'Network request failed'), { serverUrl: 'https://mail.example.com', diff --git a/src/lib/__tests__/oauth-pairing.test.ts b/src/lib/__tests__/oauth-pairing.test.ts index 5f89b175..978ce3b0 100644 --- a/src/lib/__tests__/oauth-pairing.test.ts +++ b/src/lib/__tests__/oauth-pairing.test.ts @@ -12,10 +12,13 @@ import { PAIRING_REDEEM_TIMEOUT_MS, PairingError, TransientRefreshError, + insecurePairingLinkError, + isInsecurePairingUrl, parsePastedSignInLink, parseQrLoginPayload, redeemPairingCode, refreshOAuthAccessToken, + signInLinkHost, } from '../oauth'; const CODE = 'a1'.repeat(32); @@ -110,6 +113,64 @@ describe('parsePastedSignInLink', () => { }); }); +describe('insecurePairingLinkError', () => { + const httpLink = `bulwarkmail://pair?server=${encodeURIComponent('http://Mail.Example.com:8080/webmail')}&code=${CODE}`; + + it('says why a pairing link for a plain-http webmail is refused', () => { + // Still refused by the parser: nothing can redeem it. + expect(parseQrLoginPayload(httpLink)).toBeNull(); + expect(parsePastedSignInLink(`Open ${httpLink} on your phone.`)).toBeNull(); + + for (const text of [httpLink, `Open ${httpLink}.`, `bulwarkmail://pair/?code=${CODE}&server=http://mail.example.com:8080`]) { + const err = insecurePairingLinkError(text); + expect(err).toBeInstanceOf(PairingError); + expect(err).toMatchObject({ reason: 'insecure', host: 'mail.example.com:8080' }); + } + }); + + it('names the host the code would really go to', () => { + const spoof = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org@attacker.example')}&code=${CODE}`; + expect(insecurePairingLinkError(spoof)).toMatchObject({ host: 'attacker.example' }); + }); + + it('is null for links that are fine or broken for another reason', () => { + expect(insecurePairingLinkError(`bulwarkmail://pair?server=${encodeURIComponent(WEBMAIL)}&code=${CODE}`)).toBeNull(); + expect(insecurePairingLinkError(`bulwarkmail://pair?server=http%3A%2F%2Flocalhost%3A3000&code=${CODE}`)).toBeNull(); + expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com&code=has%20space')).toBeNull(); + expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com')).toBeNull(); + expect(insecurePairingLinkError('bulwarkmail://connect?server=http://mail.example.com')).toBeNull(); + expect(insecurePairingLinkError(`bulwarkmail://pair?server=ftp://mail.example.com&code=${CODE}`)).toBeNull(); + expect(insecurePairingLinkError('http://mail.example.com')).toBeNull(); + expect(insecurePairingLinkError('')).toBeNull(); + }); +}); + +describe('isInsecurePairingUrl', () => { + it('allows https and loopback http only', () => { + expect(isInsecurePairingUrl('https://mail.example.com')).toBe(false); + expect(isInsecurePairingUrl('http://localhost:3000')).toBe(false); + expect(isInsecurePairingUrl('http://10.0.2.2:3000')).toBe(false); + expect(isInsecurePairingUrl('http://mail.example.com')).toBe(true); + expect(isInsecurePairingUrl('http://192.168.1.5:3000')).toBe(true); + }); +}); + +describe('signInLinkHost', () => { + it('names the host the link talks to', () => { + expect(signInLinkHost('https://mail.example.com/webmail')).toBe('mail.example.com'); + expect(signInLinkHost('https://Mail.Example.com:8443')).toBe('mail.example.com:8443'); + expect(signInLinkHost('http://127.0.0.1:3000/?x=1')).toBe('127.0.0.1:3000'); + }); + + it('is not fooled by user info or backslashes', () => { + expect(signInLinkHost('https://mail.example.com@attacker.example/webmail')).toBe('attacker.example'); + expect(signInLinkHost('https://a@mail.example.com@attacker.example')).toBe('attacker.example'); + expect(signInLinkHost('https://attacker.example\\@mail.example.com')).toBe('attacker.example'); + expect(signInLinkHost('https://attacker.example#@mail.example.com')).toBe('attacker.example'); + expect(signInLinkHost('https://attacker.example?@mail.example.com')).toBe('attacker.example'); + }); +}); + describe('redeemPairingCode', () => { it('posts the code to the webmail and returns an OAuth bundle for its token proxy', async () => { mockSecureFetch.mockResolvedValueOnce(json(200, { @@ -184,6 +245,12 @@ describe('redeemPairingCode', () => { await expect(redeemPairingCode('http://10.0.2.2:3000', CODE)).resolves.toMatchObject({ serverUrl: 'http://10.0.2.2:8080' }); }); + it('never sends a code to a plain-http webmail', async () => { + await expect(redeemPairingCode('http://webmail.example.org/mail', CODE)) + .rejects.toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'webmail.example.org' }); + expect(mockSecureFetch).not.toHaveBeenCalled(); + }); + it('tells an unknown code from an expired or a used one', async () => { expect((await redeemFailure(json(400, { error: 'invalid_code' }))).reason).toBe('invalid'); expect((await redeemFailure(json(410, { error: 'expired_code' }))).reason).toBe('expired'); diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 745737ad..c66be1d8 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -275,6 +275,47 @@ export function parseQrLoginPayload(raw: string): QrLoginPayload | null { return null; } +/** + * The code is redeemed at the webmail, and the answer carries credentials: + * only over TLS, or plain http on loopback for development. + */ +export function isInsecurePairingUrl(webmailUrl: string): boolean { + return !isHttpsUrl(webmailUrl) && !isLoopbackHttp(webmailUrl); +} + +/** + * The host a sign-in link talks to, for the user to check. Read the way the + * network stack reads the address: `https://mail.example.com@evil.example` + * and `https://evil.example\@mail.example.com` both go to evil.example, so + * that is the host named. + */ +export function signInLinkHost(url: string): string { + const authority = url.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/\\?#]/)[0]; + return authority.slice(authority.lastIndexOf('@') + 1).toLowerCase(); +} + +/** + * Why parseQrLoginPayload refused a pairing link, when the reason is its + * plain-http webmail (the code, and the credentials it buys, would cross the + * network in clear), so a scanned, pasted or tapped link can say so instead + * of "not a Bulwark sign-in code". Only ever an error to show: the parser + * stays the one gate and never yields such a link. Takes the link alone or + * pasted text around it; null for anything else. + */ +export function insecurePairingLinkError(text: string): PairingError | null { + const link = (/bulwarkmail:\/\/\S+/i.exec(text)?.[0] ?? '').replace(/[)\]>.,;'"]+$/, ''); + const match = SIGN_IN_LINK_RE.exec(link); + if (!match || match[1].toLowerCase() !== 'pair') return null; + const params = new URLSearchParams(match[2]); + const server = params.get('server')?.trim(); + const code = params.get('code')?.trim(); + if (!server || !/^http:\/\/[^/?#\s]+/i.test(server) || !isInsecurePairingUrl(server)) return null; + if (!code || !PAIRING_CODE_RE.test(code)) return null; + return new PairingError('insecure', 'Pairing needs an https webmail address', { + host: signInLinkHost(server), + }); +} + /** * Pasted text: the link on its own, or a message that contains it ("Open * bulwarkmail://pair?… on your phone"). Falls back to the whole text so a @@ -340,6 +381,10 @@ function redeemFailure(status: number, body: Record | null, hos export async function redeemPairingCode(webmailUrl: string, code: string): Promise { const base = webmailUrl.replace(/\/+$/, ''); const host = displayHost(base); + // The login screen refuses these links already; never send a code in clear. + if (isInsecurePairingUrl(base)) { + throw new PairingError('insecure', 'Pairing needs an https webmail address', { host }); + } // secureFetch's native (client-certificate) path ignores `signal`, so the // deadline is also passed as `timeoutMs` and enforced here with a race; the diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 363a3cfa..6ed59783 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking'; -import { setPendingSignInLink, usePendingSignInLinkStore } from '../pending-sign-in-link'; +import { usePendingSignInLinkStore } from '../pending-sign-in-link'; import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingCalendarOpen } from '../pending-calendar-open'; import { usePendingMailSearch } from '../pending-mail-search'; @@ -284,18 +284,35 @@ describe('sign-in links', () => { expect(parseSignInLink(null)).toBeNull(); }); - it('parks a sign-in link for the login screen, which takes it once', () => { - setPendingSignInLink(null); + it('parks a sign-in link from the OS as external, for the login screen to take once', () => { + usePendingSignInLinkStore.getState().clear(); expect(acceptSignInLink('bulwarkmobile://mail')).toBe(false); expect(acceptSignInLink('mailto:a@b.co')).toBe(false); - expect(usePendingSignInLinkStore.getState().payload).toBeNull(); + expect(usePendingSignInLinkStore.getState().pending).toBeNull(); expect(acceptSignInLink(pair)).toBe(true); - expect(usePendingSignInLinkStore.getState().take()).toEqual({ - kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code, + expect(usePendingSignInLinkStore.getState().pending).toMatchObject({ + source: 'external', + payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code }, + }); + // Anyone can fire the link, so it is not run until the user confirms. + expect(usePendingSignInLinkStore.getState().take()).toMatchObject({ + needsConfirmation: true, + payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code }, }); // Taken means cleared: a second delivery of the same state signs in nothing. - expect(usePendingSignInLinkStore.getState().payload).toBeNull(); + expect(usePendingSignInLinkStore.getState().pending).toBeNull(); expect(usePendingSignInLinkStore.getState().take()).toBeNull(); + usePendingSignInLinkStore.getState().clear(); + }); + + it('parks connect links as external too', () => { + usePendingSignInLinkStore.getState().clear(); + expect(acceptSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBe(true); + expect(usePendingSignInLinkStore.getState().take()).toMatchObject({ + needsConfirmation: true, + payload: { kind: 'connect', webmailUrl: 'https://mail.example.com' }, + }); + usePendingSignInLinkStore.getState().clear(); }); }); diff --git a/src/navigation/__tests__/pending-sign-in-link.test.ts b/src/navigation/__tests__/pending-sign-in-link.test.ts new file mode 100644 index 00000000..d51b2e9c --- /dev/null +++ b/src/navigation/__tests__/pending-sign-in-link.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { acceptSignInLink, routeParkedSignInLink } from '../linking'; +import { + setPendingSignInLink, + signInLinkNeedsConfirmation, + usePendingSignInLinkStore, +} from '../pending-sign-in-link'; +import type { QrLoginPayload } from '../../lib/oauth'; +import { MAX_ACCOUNTS } from '../../lib/account-utils'; + +// A `bulwarkmail://` link from outside the app (any web page, one tap; any +// installed app, no tap) names a webmail and a code of the sender's choosing. +// It must not be redeemed or opened until the user says Continue; a scanned +// or pasted one is the user's own doing in the app and runs straight away. + +const code = 'c0ffee'.repeat(10) + 'beef'; +const link = (server: string) => + `bulwarkmail://pair?server=${encodeURIComponent(server)}&code=${code}`; +const ATTACKER = link('https://attacker.example'); +const OTHER = link('https://other.example'); + +const store = () => usePendingSignInLinkStore.getState(); + +beforeEach(() => store().clear()); + +describe('sign-in link confirmation', () => { + it('asks for links from outside the app, not for scanned or pasted ones', () => { + expect(signInLinkNeedsConfirmation('external')).toBe(true); + expect(signInLinkNeedsConfirmation('in-app')).toBe(false); + }); + + it('holds an external link until the user continues, then runs it once', () => { + acceptSignInLink(ATTACKER); + const taken = store().take(); + expect(taken).toMatchObject({ needsConfirmation: true, payload: { kind: 'pair', webmailUrl: 'https://attacker.example', code } }); + // Taken for the dialog, not handed out to run. + expect(store().pending).toBeNull(); + expect(store().awaitingConfirmation?.id).toBe(taken!.id); + expect(store().take()).toBeNull(); + + expect(store().confirm(taken!.id)).toEqual({ kind: 'pair', webmailUrl: 'https://attacker.example', code }); + expect(store().awaitingConfirmation).toBeNull(); + // A second Continue (or a stale dialog) runs nothing. + expect(store().confirm(taken!.id)).toBeNull(); + }); + + it('forgets a cancelled link, so nothing redeems it later', () => { + acceptSignInLink(ATTACKER); + const taken = store().take()!; + expect(store().discard(taken.id)).toBe(true); + expect(store().confirm(taken.id)).toBeNull(); + expect(store().take()).toBeNull(); + expect(store().pending).toBeNull(); + expect(store().awaitingConfirmation).toBeNull(); + expect(store().discard(taken.id)).toBe(false); + }); + + it('drops links delivered while the dialog is open instead of swapping them in', () => { + acceptSignInLink(ATTACKER); + const shown = store().take()!; + // A second 'url' event, or getInitialURL delivering again. + expect(acceptSignInLink(OTHER)).toBe(true); + expect(acceptSignInLink(ATTACKER)).toBe(true); + expect(store().pending).toBeNull(); + expect(store().take()).toBeNull(); + // Continue runs the link the dialog named, nothing else. + expect(store().confirm(shown.id)).toMatchObject({ webmailUrl: 'https://attacker.example' }); + expect(store().take()).toBeNull(); + }); + + it('asks again when a link is delivered again after the dialog closed', () => { + acceptSignInLink(ATTACKER); + const first = store().take()!; + store().discard(first.id); + + acceptSignInLink(ATTACKER); + const again = store().take()!; + expect(again.needsConfirmation).toBe(true); + expect(again.id).not.toBe(first.id); + // The cancelled dialog's id cannot confirm the new delivery. + expect(store().confirm(first.id)).toBeNull(); + expect(store().awaitingConfirmation?.id).toBe(again.id); + }); + + it('keeps the newest of two links parked before the login screen takes one', () => { + acceptSignInLink(ATTACKER); + acceptSignInLink(OTHER); + expect(store().take()).toMatchObject({ needsConfirmation: true, payload: { webmailUrl: 'https://other.example' } }); + }); + + it('runs an in-app link without a dialog', () => { + const payload: QrLoginPayload = { kind: 'connect', webmailUrl: 'https://mail.example.com' }; + expect(setPendingSignInLink(payload, 'in-app')).toBe(true); + expect(store().take()).toMatchObject({ needsConfirmation: false, payload }); + expect(store().awaitingConfirmation).toBeNull(); + expect(store().pending).toBeNull(); + }); +}); + +describe('refused sign-in links', () => { + it('parks a pairing link for a plain-http webmail as a refusal, with nothing to run', () => { + expect(acceptSignInLink(link('http://mail.example.com'))).toBe(true); + expect(store().pending).toBeNull(); + expect(store().take()).toBeNull(); + const refusal = store().takeRefusal(); + expect(refusal).toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'mail.example.com' }); + expect(store().takeRefusal()).toBeNull(); + }); + + it('ignores other broken sign-in links, as before', () => { + expect(acceptSignInLink(`bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com')}`)).toBe(false); + expect(acceptSignInLink('bulwarkmail://unlock?server=https://mail.example.com')).toBe(false); + expect(store().refusal).toBeNull(); + expect(store().pending).toBeNull(); + }); +}); + +describe('routeParkedSignInLink (signed in)', () => { + it('opens Add account while there is room', () => { + acceptSignInLink(ATTACKER); + expect(routeParkedSignInLink(MAX_ACCOUNTS - 1)).toBe('add-account'); + // Still parked for Add account's login screen. + expect(store().pending).not.toBeNull(); + }); + + it('drops the link and reports the limit when the registry is full, so no code is spent', () => { + acceptSignInLink(ATTACKER); + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit'); + expect(store().pending).toBeNull(); + expect(store().take()).toBeNull(); + + acceptSignInLink(link('http://mail.example.com')); + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit'); + expect(store().refusal).toBeNull(); + }); + + it('does nothing when no link is parked', () => { + expect(routeParkedSignInLink(0)).toBe('none'); + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none'); + }); + + it('leaves a link the dialog is asking about alone', () => { + acceptSignInLink(ATTACKER); + const shown = store().take()!; + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none'); + expect(store().confirm(shown.id)).not.toBeNull(); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index f45af146..7c621b4e 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -9,8 +9,9 @@ import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingCalendarOpen } from './pending-calendar-open'; import { setPendingMailSearch } from './pending-mail-search'; -import { setPendingSignInLink } from './pending-sign-in-link'; -import { parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; +import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; +import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; +import { MAX_ACCOUNTS } from '../lib/account-utils'; export const APP_SCHEME = 'bulwarkmobile'; // The webmail's "Link Mobile App" links (`bulwarkmail://pair?server=…&code=…`) @@ -184,17 +185,41 @@ export function parseSignInLink(url: string | null | undefined): QrLoginPayload } /** - * Park a tapped sign-in link for the login screen, which takes it (the - * signed-out one, or Add account when someone is signed in). Returns whether - * `url` was one. + * Park a sign-in link the OS delivered (a tapped link, or another app firing + * the intent) for the login screen, which takes it (the signed-out one, or + * Add account when someone is signed in) and asks the user before running + * it: whoever sent it chose the webmail and the code. A pairing link for a + * plain-http webmail is parked as a refusal instead, for the login screen to + * say why nothing happens. Returns whether `url` was a sign-in link. */ export function acceptSignInLink(url: string | null | undefined): boolean { const payload = parseSignInLink(url); - if (!payload) return false; - setPendingSignInLink(payload); + if (payload) { + setPendingSignInLink(payload, 'external'); + return true; + } + const refusal = url && isSignInSchemeUrl(url) ? insecurePairingLinkError(url) : null; + if (!refusal) return false; + usePendingSignInLinkStore.getState().refuse(refusal); return true; } +/** + * Signed in, where a parked sign-in link goes: Add account, whose login + * screen takes it, or, with no room for another account, nowhere: the link + * is dropped before a code is spent and the caller shows the limit. 'none' + * when nothing is parked (taken meanwhile by an Add account already open). + */ +export function routeParkedSignInLink(accountCount: number): 'add-account' | 'account-limit' | 'none' { + const links = usePendingSignInLinkStore.getState(); + if (!links.pending && !links.refusal) return 'none'; + if (accountCount >= MAX_ACCOUNTS) { + links.dropParked(); + return 'account-limit'; + } + return 'add-account'; +} + export interface DeepLinkNavigator { navigation: NavigationContainerRefWithCurrent; // Resolve a message id to its thread (EmailThread needs both). Returns diff --git a/src/navigation/pending-sign-in-link.ts b/src/navigation/pending-sign-in-link.ts index 72046404..c8c5fa53 100644 --- a/src/navigation/pending-sign-in-link.ts +++ b/src/navigation/pending-sign-in-link.ts @@ -1,26 +1,115 @@ import { create } from 'zustand'; -import type { QrLoginPayload } from '../lib/oauth'; +import type { PairingError, QrLoginPayload } from '../lib/oauth'; // A tapped `bulwarkmail://` sign-in link arrives before the login screen is // up (cold start, or signed in with Add account not open yet). It is parked // here and taken by LoginScreen, which runs it like a scanned code. Taking // clears it, so one link signs in once however often it is delivered. -interface PendingSignInLinkState { - payload: QrLoginPayload | null; - set: (payload: QrLoginPayload | null) => void; - take: () => QrLoginPayload | null; +// +// Where a link came from decides whether the user is asked first: +// - `external`: the OS handed it to the app. Any web page (one tap) or any +// installed app (no tap at all) can fire one, with a webmail and a code of +// its own choosing, so the user confirms before it is redeemed or opened. +// - `in-app`: scanned with the app's scanner or pasted into its paste box. +// The user just did that in the app, so it runs straight away. +export type SignInLinkSource = 'external' | 'in-app'; + +export interface PendingSignInLink { + // Tells one delivery from the next, so a dialog can only ever confirm the + // link it named. + id: number; + payload: QrLoginPayload; + source: SignInLinkSource; } +export interface TakenSignInLink { + id: number; + payload: QrLoginPayload; + // Show the confirmation and run the link only on Continue (`confirm`). + needsConfirmation: boolean; +} + +export function signInLinkNeedsConfirmation(source: SignInLinkSource): boolean { + return source !== 'in-app'; +} + +interface PendingSignInLinkState { + // Parked, not taken by a login screen yet. + pending: PendingSignInLink | null; + // Taken and shown to the user, waiting for Continue or Cancel. + awaitingConfirmation: PendingSignInLink | null; + // A tapped link that is refused outright (a pairing code for a plain-http + // webmail): nothing to run or confirm, the login screen says why. + refusal: PairingError | null; + /** Park a link. Returns false when it was dropped (a dialog is open). */ + park: (payload: QrLoginPayload, source: SignInLinkSource) => boolean; + /** + * Take the parked link. One that needs confirmation moves to + * `awaitingConfirmation`; run it only with what `confirm` returns. + */ + take: () => TakenSignInLink | null; + /** Continue: the payload to run, or null when that link is gone. */ + confirm: (id: number) => QrLoginPayload | null; + /** + * Cancel: forget the link, so nothing redeems it later. Returns whether it + * was still waiting. + */ + discard: (id: number) => boolean; + refuse: (refusal: PairingError) => void; + takeRefusal: () => PairingError | null; + /** Drop what is parked (not a link a dialog is asking about). */ + dropParked: () => boolean; + clear: () => void; +} + +let nextId = 1; + export const usePendingSignInLinkStore = create((set, get) => ({ - payload: null, - set: (payload) => set({ payload }), - take: () => { - const payload = get().payload; - if (payload) set({ payload: null }); - return payload; + pending: null, + awaitingConfirmation: null, + refusal: null, + park: (payload, source) => { + // While the user is asked about one link, another is dropped rather + // than queued behind it or swapped in: Continue must run the link the + // dialog names and nothing else, and a link fired while the dialog is + // open must not pop up right after Cancel. + if (get().awaitingConfirmation) return false; + set({ pending: { id: nextId++, payload, source } }); + return true; }, + take: () => { + const { pending, awaitingConfirmation } = get(); + if (!pending || awaitingConfirmation) return null; + const needsConfirmation = signInLinkNeedsConfirmation(pending.source); + set({ pending: null, awaitingConfirmation: needsConfirmation ? pending : null }); + return { id: pending.id, payload: pending.payload, needsConfirmation }; + }, + confirm: (id) => { + const link = get().awaitingConfirmation; + if (!link || link.id !== id) return null; + set({ awaitingConfirmation: null }); + return link.payload; + }, + discard: (id) => { + if (get().awaitingConfirmation?.id !== id) return false; + set({ awaitingConfirmation: null }); + return true; + }, + refuse: (refusal) => set({ refusal }), + takeRefusal: () => { + const refusal = get().refusal; + if (refusal) set({ refusal: null }); + return refusal; + }, + dropParked: () => { + const { pending, refusal } = get(); + if (!pending && !refusal) return false; + set({ pending: null, refusal: null }); + return true; + }, + clear: () => set({ pending: null, awaitingConfirmation: null, refusal: null }), })); -export function setPendingSignInLink(payload: QrLoginPayload | null): void { - usePendingSignInLinkStore.getState().set(payload); +export function setPendingSignInLink(payload: QrLoginPayload, source: SignInLinkSource): boolean { + return usePendingSignInLinkStore.getState().park(payload, source); } diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 49a87a34..982d9389 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -1,12 +1,17 @@ import React from 'react'; -import { BackHandler } from 'react-native'; +import { Alert, BackHandler } from 'react-native'; import { useAuthStore } from '../stores/auth-store'; import { useAccountStore } from '../stores/account-store'; -import { useLocaleStore } from '../stores/locale-store'; +import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { QrScanModal } from '../components/QrScanModal'; import { PasteSignInLinkModal } from '../components/PasteSignInLinkModal'; -import { parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; +import { + insecurePairingLinkError, + parseQrLoginPayload, + signInLinkHost, + type QrLoginPayload, +} from '../lib/oauth'; import { usePendingSignInLinkStore } from '../navigation/pending-sign-in-link'; import { discoverServerForEmail, @@ -33,6 +38,31 @@ type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password'; const RECENT_EMAILS_KEY = 'login:recentEmails:v1'; +// The question asked before running a sign-in link from outside the app: +// which webmail it talks to, and whether it adds an account (and switches to +// it) or signs in. +function signInLinkConfirmation( + payload: QrLoginPayload, + isAddMode: boolean, + t: TranslateFn, +): { title: string; message: string } { + const host = signInLinkHost(payload.webmailUrl); + if (payload.kind === 'connect') { + return { + title: t('login.mobile.link_confirm_connect_title', 'Open the sign-in page of {host}?', { host }), + message: isAddMode + ? t('login.mobile.link_confirm_connect_add', 'A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.', { host }) + : t('login.mobile.link_confirm_connect', 'A link asks to sign in on this page. Only continue if you use {host} for your mail.', { host }), + }; + } + return { + title: t('login.mobile.link_confirm_title', 'Sign in with a link?'), + message: isAddMode + ? t('login.mobile.link_confirm_pair_add', 'This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.', { host }) + : t('login.mobile.link_confirm_pair', 'This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.', { host }), + }; +} + /** * Sign-in, one question per screen, cheapest question first. * @@ -334,6 +364,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo setScannerVisible(false); const payload = parseQrLoginPayload(data); if (!payload) { + // A pairing code for a plain-http webmail: say why it won't be used. + const insecure = insecurePairingLinkError(data); + if (insecure) { + setNotice(describeLoginError(insecure, { t })); + return; + } setNotice({ title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"), detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link Mobile App to show one.'), @@ -345,14 +381,84 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo [handleSignInLink, t], ); - // A tapped sign-in link is parked by App until a login screen is free to - // run it; taking it clears it, so one link signs in once. - const signInLinkPending = usePendingSignInLinkStore((s) => s.payload !== null); + // A sign-in link from outside the app (a tapped link, or one another app + // fired) is parked by App until a login screen is free to take it; taking + // clears it, so one link runs once. Whoever sent it chose the webmail and + // the code, so the user confirms first: nothing is redeemed or opened + // before Continue, and Cancel (or dismissing the dialog) forgets the link. + // Scanned and pasted links skip this: they call handleSignInLink directly. + const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null); + // The dialog answers after later renders; run the current callbacks. + const latestRef = React.useRef({ handleSignInLink, onCancel }); + React.useEffect(() => { + latestRef.current = { handleSignInLink, onCancel }; + }, [handleSignInLink, onCancel]); + const mountedRef = React.useRef(false); + const confirmingLinkIdRef = React.useRef(null); + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + // Gone while the dialog is up: forget its link, so a late Continue runs + // nothing and later links are not dropped as "dialog open". Checked a + // tick later, as a StrictMode rehearsal remounts straight away. + setTimeout(() => { + const id = confirmingLinkIdRef.current; + if (!mountedRef.current && id !== null) usePendingSignInLinkStore.getState().discard(id); + }, 0); + }; + }, []); React.useEffect(() => { if (!signInLinkPending || busy || searching) return; - const payload = usePendingSignInLinkStore.getState().take(); - if (payload) void handleSignInLink(payload); - }, [busy, handleSignInLink, searching, signInLinkPending]); + const taken = usePendingSignInLinkStore.getState().take(); + if (!taken) return; + if (!taken.needsConfirmation) { + void handleSignInLink(taken.payload); + return; + } + // Add account opened for this link closes again on Cancel; one the user + // was already working through stays where it was. + const leaveOnCancel = isAddMode && history.length === 0; + confirmingLinkIdRef.current = taken.id; + let answered = false; + const answer = (proceed: boolean) => { + if (answered) return; + answered = true; + confirmingLinkIdRef.current = null; + const store = usePendingSignInLinkStore.getState(); + if (!mountedRef.current) { + store.discard(taken.id); + return; + } + if (proceed) { + // Only the link this dialog named, and only if it is still waiting. + const payload = store.confirm(taken.id); + if (payload) void latestRef.current.handleSignInLink(payload); + return; + } + if (store.discard(taken.id) && leaveOnCancel) latestRef.current.onCancel?.(); + }; + const { title, message } = signInLinkConfirmation(taken.payload, isAddMode, t); + Alert.alert( + title, + message, + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) }, + { text: t('login.mobile.continue', 'Continue'), onPress: () => answer(true) }, + ], + // Android back or a tap outside the dialog counts as Cancel. + { cancelable: true, onDismiss: () => answer(false) }, + ); + }, [busy, handleSignInLink, history.length, isAddMode, searching, signInLinkPending, t]); + + // A tapped link that is refused outright (a pairing code for a plain-http + // webmail) ran nothing; say why, as for a scanned one. + const signInLinkRefused = usePendingSignInLinkStore((s) => s.refusal !== null); + React.useEffect(() => { + if (!signInLinkRefused || busy || searching) return; + const refusal = usePendingSignInLinkStore.getState().takeRefusal(); + if (refusal) setNotice(describeLoginError(refusal, { t })); + }, [busy, searching, signInLinkRefused, t]); // ── render ─────────────────────────────────────────────