mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: ask before a sign-in link from another app signs in
Any web page (one tap) or installed app (no tap) could fire a `bulwarkmail://pair` link at the app, which then silently added the sender's mailbox and switched to it, so the user might send or save mail into an attacker's account. A `connect` link likewise opened any server's sign-in page. - Links that arrive from outside the app now ask first, naming the webmail's host and whether the link adds an account. Cancel discards the link for good; a link delivered again while the question is open is dropped. Codes scanned or pasted inside the app still go straight through. - With the account registry full, a link shows the limit instead of opening "Add account". - A pairing link for a plain-http webmail says the connection would be unencrypted instead of "That code isn't a Bulwark sign-in code", or nothing at all for a tapped link.
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 نیست",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 のサインインコードではありません",
|
||||
|
||||
@@ -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 로그인 코드가 아니에요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-ийн нэвтрэх код биш байна",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 登入碼",
|
||||
|
||||
@@ -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 登录码",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, unknown> | null, hos
|
||||
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<RootStackParamList>;
|
||||
// Resolve a message id to its thread (EmailThread needs both). Returns
|
||||
|
||||
@@ -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<PendingSignInLinkState>((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);
|
||||
}
|
||||
|
||||
+115
-9
@@ -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<number | null>(null);
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
// Gone while the dialog is up: forget its link, so a late Continue runs
|
||||
// nothing and later links are not dropped as "dialog open". Checked a
|
||||
// tick later, as a StrictMode rehearsal remounts straight away.
|
||||
setTimeout(() => {
|
||||
const id = confirmingLinkIdRef.current;
|
||||
if (!mountedRef.current && id !== null) usePendingSignInLinkStore.getState().discard(id);
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!signInLinkPending || busy || searching) return;
|
||||
const 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 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user