diff --git a/App.tsx b/App.tsx index 31b0ddc7..28c60920 100644 --- a/App.tsx +++ b/App.tsx @@ -73,7 +73,8 @@ 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 { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; +import { acceptSignInLink, handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; +import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; import { useOfflineCacheStore } from './src/stores/offline-cache-store'; @@ -86,6 +87,10 @@ const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); const navigationRef = createNavigationContainerRef(); +// The launch URL is read once per process: Android hands the same intent back +// on every getInitialURL(), and a sign-in link must not run twice. +let initialSignInLinkRead = false; + async function navigateToNotificationTap(payload: NotificationTapPayload): Promise { if (!navigationRef.isReady()) return; @@ -566,6 +571,40 @@ export default function App() { }; }, [isAuthenticated]); + // 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. + React.useEffect(() => { + if (!initialSignInLinkRead) { + initialSignInLinkRead = true; + void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined); + } + const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); }); + return () => subscription.remove(); + }, []); + + // 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); + const addAccountForLink = isAuthenticated && signInLinkPending; + React.useEffect(() => { + if (!addAccountForLink) return; + let cancelled = false; + void (async () => { + for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (cancelled) return; + } + if (cancelled || !navigationRef.isReady()) return; + // Taken meanwhile (Add account was already open). + if (!usePendingSignInLinkStore.getState().payload) return; + navigationRef.navigate('AddAccount'); + })(); + return () => { cancelled = true; }; + }, [addAccountForLink]); + // Re-register the device with the configured relay once authenticated, // and whenever the FCM token rotates. Honours the user's notification // preference - flipping it off tears down THIS account's subscription so diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1c2731e4..55ff3375 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,13 @@ + + + + + + + diff --git a/app.config.js b/app.config.js index 5e8015db..a1f50e7f 100644 --- a/app.config.js +++ b/app.config.js @@ -26,8 +26,10 @@ module.exports = { expo: { name: 'Bulwark Mobile', slug: 'bulwark-mobile', - // mailto: lets Android/iOS offer the app for mail links in other apps. - scheme: ['bulwarkmobile', 'mailto'], + // mailto: lets Android/iOS offer the app for mail links in other apps; + // bulwarkmail: is the webmail's sign-in link (bulwarkmail://pair?...). + // Android's manifest is maintained by hand and lists the same schemes. + scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'], version: VERSION, orientation: 'portrait', icon: './assets/icon.png', diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 4f59a281..17e2a88b 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -522,6 +522,12 @@ "pairing_detail": "جارٍ إقران هذا الجهاز مع {host}…", "password_subtitle": "تُرسَل كلمة مرورك إلى هذا الخادم فقط، وتُخزَّن في سلسلة مفاتيح الجهاز.", "password_title": "تسجيل الدخول إلى {host}", + "paste_button": "لصق", + "paste_clipboard_empty": "لا يوجد شيء للصق. انسخ الرابط في بريد الويب أولًا.", + "paste_hint": "في بريد الويب، افتح الإعدادات ← الأمان ← ربط تطبيق الجوال، وانسخ رابط تسجيل الدخول والصقه هنا.", + "paste_invalid": "هذا ليس رابط تسجيل دخول Bulwark", + "paste_label": "رابط تسجيل الدخول", + "paste_title": "لصق رابط تسجيل الدخول", "provider_detail": "ستكتبها في صفحتهم. لا يخزّن Bulwark سوى رمز تسجيل دخول يمكنك إبطاله في أي وقت.", "provider_title": "تبقى كلمة مرورك لدى مزوّد خدمتك", "qr_hint": "يعرض قسم الإعدادات ← الأمان ← ربط تطبيق الجوال رمزًا. وجّه الكاميرا نحوه.", @@ -532,6 +538,7 @@ "server_hint": "العنوان نفسه الذي تكتبه في المتصفح. سنضيف https:// نيابةً عنك.", "server_label": "عنوان الخادم", "server_not_found": "لم نتمكن من العثور على خادم للنطاق {domain}. إذا كنت تستضيف خادمك بنفسك، فالعنوان هو الذي تستخدمه لبريد الويب.", + "server_paste_hint": "هل لديك رابط تسجيل دخول بدلًا من ذلك؟ الصقه هنا.", "server_scan_hint": "إذا لم تكن متأكدًا، فامسح رمز تسجيل الدخول بدلًا من ذلك، فهو يتضمن عنوان الخادم.", "server_subtitle": "أدخل العنوان الذي تستخدمه للوصول إلى بريد الويب.", "server_title": "ما عنوان خادمك؟", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index f3bf3bdb..7b04a14a 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -522,6 +522,12 @@ "pairing_detail": "S'està aparellant aquest dispositiu amb {host}…", "password_subtitle": "La contrasenya només s'envia a aquest servidor i es desa al magatzem segur del dispositiu.", "password_title": "Inicia la sessió a {host}", + "paste_button": "Enganxa", + "paste_clipboard_empty": "No hi ha res per enganxar. Primer copieu l'enllaç al webmail.", + "paste_hint": "Al webmail, obriu Configuració → Seguretat → Enllaça l'aplicació mòbil, copieu l'enllaç d'inici de sessió i enganxeu-lo aquí.", + "paste_invalid": "Això no és un enllaç d'inici de sessió del Bulwark", + "paste_label": "Enllaç d'inici de sessió", + "paste_title": "Enganxa l'enllaç d'inici de sessió", "provider_detail": "L'escriureu a la seva pàgina. El Bulwark només desa un testimoni d'inici de sessió, que podeu revocar en qualsevol moment.", "provider_title": "La contrasenya es queda amb el vostre proveïdor", "qr_hint": "Configuració → Seguretat → Enllaça l'aplicació mòbil mostra un codi. Apunteu-hi la càmera.", @@ -532,6 +538,7 @@ "server_hint": "La mateixa adreça que escriviu al navegador. Hi afegirem https://.", "server_label": "Adreça del servidor", "server_not_found": "No hem trobat cap servidor per a {domain}. Si l'allotgeu vosaltres, l'adreça és la que utilitzeu per al webmail.", + "server_paste_hint": "Teniu un enllaç d'inici de sessió? Enganxeu-lo aquí.", "server_scan_hint": "Si no n'esteu segur, escanegeu un codi d'inici de sessió. Inclou l'adreça del servidor.", "server_subtitle": "Introduïu l'adreça que utilitzeu per accedir al webmail.", "server_title": "Quina és l'adreça del vostre servidor?", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 6bcc16f2..19a093e9 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -522,6 +522,12 @@ "pairing_detail": "Párování tohoto zařízení: {host}…", "password_subtitle": "Heslo se odešle pouze tomuto serveru a uloží se do úložiště klíčů v zařízení.", "password_title": "Přihlásit se k {host}", + "paste_button": "Vložit", + "paste_clipboard_empty": "Není co vložit. Nejprve zkopírujte odkaz ve webmailu.", + "paste_hint": "Ve webmailu otevřete Nastavení → Zabezpečení → Propojit mobilní aplikaci, zkopírujte přihlašovací odkaz a vložte ho sem.", + "paste_invalid": "Toto není přihlašovací odkaz Bulwark", + "paste_label": "Přihlašovací odkaz", + "paste_title": "Vložit přihlašovací odkaz", "provider_detail": "Zadáte ho na jeho stránce. Bulwark ukládá pouze přihlašovací token, který můžete kdykoli odvolat.", "provider_title": "Vaše heslo zůstává u poskytovatele", "qr_hint": "V Nastavení → Zabezpečení → Propojit mobilní aplikaci se zobrazí kód. Namiřte na něj fotoaparát.", @@ -532,6 +538,7 @@ "server_hint": "Stejná adresa, jakou zadáváte v prohlížeči. https:// doplníme za vás.", "server_label": "Adresa serveru", "server_not_found": "Pro {domain} jsme nenašli žádný server. Pokud provozujete vlastní server, zadejte adresu, kterou používáte pro webmail.", + "server_paste_hint": "Máte místo toho přihlašovací odkaz? Vložte ho sem.", "server_scan_hint": "Pokud si nejste jisti, naskenujte raději přihlašovací kód. Obsahuje adresu serveru.", "server_subtitle": "Zadejte adresu, přes kterou otevíráte webmail.", "server_title": "Jaká je adresa vašeho serveru?", diff --git a/locales/rn/da.json b/locales/rn/da.json index cbe31681..066ad2f4 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -522,6 +522,12 @@ "pairing_detail": "Parrer denne enhed med {host}…", "password_subtitle": "Din adgangskode sendes kun til denne server og gemmes i enhedens nøglering.", "password_title": "Log ind på {host}", + "paste_button": "Indsæt", + "paste_clipboard_empty": "Der er intet at indsætte. Kopiér først linket i webmailen.", + "paste_hint": "Åbn Indstillinger → Sikkerhed → Tilknyt mobilapp i webmailen, kopiér loginlinket, og indsæt det her.", + "paste_invalid": "Det er ikke et Bulwark-loginlink", + "paste_label": "Loginlink", + "paste_title": "Indsæt loginlink", "provider_detail": "Du indtaster den på deres side. Bulwark gemmer kun et login-token, som du til enhver tid kan tilbagekalde.", "provider_title": "Din adgangskode bliver hos din udbyder", "qr_hint": "Indstillinger → Sikkerhed → Tilknyt mobilapp viser en kode. Ret kameraet mod den.", @@ -532,6 +538,7 @@ "server_hint": "Samme adresse, som du skriver i browseren. Vi tilføjer https:// for dig.", "server_label": "Serveradresse", "server_not_found": "Vi kunne ikke finde en server for {domain}. Hvis du selv hoster, er det den adresse, du bruger til webmail.", + "server_paste_hint": "Har du i stedet et loginlink? Indsæt det her.", "server_scan_hint": "Hvis du er i tvivl, så scan i stedet en loginkode. Den indeholder serveradressen.", "server_subtitle": "Indtast den adresse, du bruger til at nå din webmail.", "server_title": "Hvad er din serveradresse?", diff --git a/locales/rn/de.json b/locales/rn/de.json index 6cc5810d..fc010201 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -522,6 +522,12 @@ "pairing_detail": "Dieses Gerät wird mit {host} gekoppelt…", "password_subtitle": "Ihr Passwort wird nur an diesen Server gesendet und im Schlüsselbund des Geräts gespeichert.", "password_title": "Bei {host} anmelden", + "paste_button": "Einfügen", + "paste_clipboard_empty": "Nichts zum Einfügen. Kopieren Sie zuerst den Link im Webmail.", + "paste_hint": "Öffnen Sie im Webmail Einstellungen → Sicherheit → Mobile App verknüpfen, kopieren Sie den Anmeldelink und fügen Sie ihn hier ein.", + "paste_invalid": "Das ist kein Bulwark-Anmeldelink", + "paste_label": "Anmeldelink", + "paste_title": "Anmeldelink einfügen", "provider_detail": "Sie geben es auf dessen Seite ein. Bulwark speichert nur ein Anmeldetoken, das Sie jederzeit widerrufen können.", "provider_title": "Ihr Passwort bleibt bei Ihrem Anbieter", "qr_hint": "Einstellungen → Sicherheit → Mobile App verknüpfen zeigt einen Code an. Richten Sie die Kamera darauf.", @@ -532,6 +538,7 @@ "server_hint": "Dieselbe Adresse, die Sie im Browser eingeben. https:// ergänzen wir für Sie.", "server_label": "Serveradresse", "server_not_found": "Für {domain} wurde kein Server gefunden. Wenn Sie selbst hosten, ist es die Adresse, die Sie für das Webmail verwenden.", + "server_paste_hint": "Haben Sie stattdessen einen Anmeldelink? Fügen Sie ihn hier ein.", "server_scan_hint": "Wenn Sie unsicher sind, scannen Sie stattdessen einen Anmeldecode. Er enthält die Serveradresse.", "server_subtitle": "Geben Sie die Adresse ein, unter der Sie Ihr Webmail erreichen.", "server_title": "Wie lautet Ihre Serveradresse?", diff --git a/locales/rn/en.json b/locales/rn/en.json index e12221de..570da195 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -532,6 +532,12 @@ "pairing_detail": "Pairing this device with {host}…", "password_subtitle": "Your password is sent to this server only, and stored in the device keychain.", "password_title": "Sign in to {host}", + "paste_button": "Paste", + "paste_clipboard_empty": "Nothing to paste. Copy the link in the webmail first.", + "paste_hint": "In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.", + "paste_invalid": "That isn't a Bulwark sign-in link", + "paste_label": "Sign-in link", + "paste_title": "Paste sign-in link", "provider_detail": "You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time.", "provider_title": "Your password stays with your provider", "qr_hint": "Settings → Security → Link Mobile App shows a code. Point the camera at it.", @@ -542,6 +548,7 @@ "server_hint": "Same address you type in the browser. We'll add https:// for you.", "server_label": "Server address", "server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.", + "server_paste_hint": "Have a sign-in link instead? Paste it here.", "server_scan_hint": "If you're not sure, scan a sign-in code instead. It carries the server address.", "server_subtitle": "Enter the address you use to reach your webmail.", "server_title": "What's your server address?", diff --git a/locales/rn/es.json b/locales/rn/es.json index 58f02755..c042e29e 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -522,6 +522,12 @@ "pairing_detail": "Vinculando este dispositivo con {host}…", "password_subtitle": "Tu contraseña solo se envía a este servidor y se guarda en el almacén seguro del dispositivo.", "password_title": "Iniciar sesión en {host}", + "paste_button": "Pegar", + "paste_clipboard_empty": "No hay nada que pegar. Copia primero el enlace en el webmail.", + "paste_hint": "En el webmail, abre Configuración → Seguridad → Vincular aplicación móvil, copia el enlace de inicio de sesión y pégalo aquí.", + "paste_invalid": "Eso no es un enlace de inicio de sesión de Bulwark", + "paste_label": "Enlace de inicio de sesión", + "paste_title": "Pegar enlace de inicio de sesión", "provider_detail": "La escribirás en su página. Bulwark solo guarda un token de inicio de sesión, que puedes revocar en cualquier momento.", "provider_title": "Tu contraseña se queda con tu proveedor", "qr_hint": "Configuración → Seguridad → Vincular aplicación móvil muestra un código. Apunta la cámara hacia él.", @@ -532,6 +538,7 @@ "server_hint": "La misma dirección que escribes en el navegador. Añadiremos https:// por ti.", "server_label": "Dirección del servidor", "server_not_found": "No encontramos ningún servidor para {domain}. Si lo alojas tú, la dirección es la que usas para el webmail.", + "server_paste_hint": "¿Tienes un enlace de inicio de sesión? Pégalo aquí.", "server_scan_hint": "Si no estás seguro, escanea un código de inicio de sesión. Incluye la dirección del servidor.", "server_subtitle": "Introduce la dirección que usas para acceder a tu webmail.", "server_title": "¿Cuál es la dirección de tu servidor?", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index e37d6372..ac2b4ae7 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -522,6 +522,12 @@ "pairing_detail": "در حال جفت کردن این دستگاه با {host}…", "password_subtitle": "رمز عبور شما فقط به این سرور فرستاده می‌شود و در انبار امن دستگاه ذخیره می‌شود.", "password_title": "ورود به {host}", + "paste_button": "چسباندن", + "paste_clipboard_empty": "چیزی برای چسباندن نیست. ابتدا پیوند را در ایمیل تحت وب کپی کنید.", + "paste_hint": "در ایمیل تحت وب، تنظیمات ← امنیت ← اتصال موبایل را باز کنید، پیوند ورود را کپی کنید و اینجا بچسبانید.", + "paste_invalid": "این پیوند ورود Bulwark نیست", + "paste_label": "پیوند ورود", + "paste_title": "چسباندن پیوند ورود", "provider_detail": "آن را در صفحه آن‌ها وارد می‌کنید. Bulwark فقط یک توکن ورود نگه می‌دارد که هر زمان بخواهید می‌توانید باطلش کنید.", "provider_title": "رمز عبور شما نزد ارائه‌دهنده‌تان می‌ماند", "qr_hint": "در تنظیمات ← امنیت ← اتصال موبایل یک کد نمایش داده می‌شود. دوربین را به سمت آن بگیرید.", @@ -532,6 +538,7 @@ "server_hint": "همان نشانی‌ای که در مرورگر تایپ می‌کنید. https:// را خودمان اضافه می‌کنیم.", "server_label": "نشانی سرور", "server_not_found": "سروری برای {domain} پیدا نکردیم. اگر سرور خودتان را میزبانی می‌کنید، نشانی همان است که برای ایمیل تحت وب استفاده می‌کنید.", + "server_paste_hint": "به‌جای آن پیوند ورود دارید؟ اینجا بچسبانید.", "server_scan_hint": "اگر مطمئن نیستید، به‌جای آن کد ورود را اسکن کنید. نشانی سرور در آن هست.", "server_subtitle": "نشانی‌ای را که با آن به ایمیل تحت وب دسترسی دارید وارد کنید.", "server_title": "نشانی سرور شما چیست؟", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 0c5f7085..7190c456 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -522,6 +522,12 @@ "pairing_detail": "Association de cet appareil avec {host}…", "password_subtitle": "Votre mot de passe n'est envoyé qu'à ce serveur et est stocké dans le trousseau de l'appareil.", "password_title": "Se connecter à {host}", + "paste_button": "Coller", + "paste_clipboard_empty": "Rien à coller. Copiez d'abord le lien dans le webmail.", + "paste_hint": "Dans le webmail, ouvrez Paramètres → Sécurité → Associer l'application mobile, copiez le lien de connexion et collez-le ici.", + "paste_invalid": "Ce n'est pas un lien de connexion Bulwark", + "paste_label": "Lien de connexion", + "paste_title": "Coller le lien de connexion", "provider_detail": "Vous le saisirez sur leur page. Bulwark ne stocke qu'un jeton de connexion, que vous pouvez révoquer à tout moment.", "provider_title": "Votre mot de passe reste chez votre fournisseur", "qr_hint": "Paramètres → Sécurité → Associer l'application mobile affiche un code. Pointez l'appareil photo dessus.", @@ -532,6 +538,7 @@ "server_hint": "La même adresse que celle saisie dans le navigateur. Nous ajoutons https:// pour vous.", "server_label": "Adresse du serveur", "server_not_found": "Aucun serveur trouvé pour {domain}. Si vous l'hébergez vous-même, l'adresse est celle que vous utilisez pour le webmail.", + "server_paste_hint": "Vous avez plutôt un lien de connexion ? Collez-le ici.", "server_scan_hint": "En cas de doute, scannez plutôt un code de connexion. Il contient l'adresse du serveur.", "server_subtitle": "Saisissez l'adresse que vous utilisez pour accéder à votre webmail.", "server_title": "Quelle est l'adresse de votre serveur ?", diff --git a/locales/rn/he.json b/locales/rn/he.json index 559b6a52..5f2a00f5 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -522,6 +522,12 @@ "pairing_detail": "מצמיד את המכשיר הזה אל {host}…", "password_subtitle": "הסיסמה שלך נשלחת לשרת הזה בלבד ונשמרת במחזיק המפתחות של המכשיר.", "password_title": "התחברות אל {host}", + "paste_button": "הדבק", + "paste_clipboard_empty": "אין מה להדביק. העתק קודם את הקישור בדואר האינטרנט.", + "paste_hint": "בדואר האינטרנט, פתח את הגדרות ← אבטחה ← הקשר יישום נייד, העתק את קישור ההתחברות והדבק אותו כאן.", + "paste_invalid": "זה לא קישור התחברות של Bulwark", + "paste_label": "קישור התחברות", + "paste_title": "הדבק קישור התחברות", "provider_detail": "תקליד אותה בדף שלהם. Bulwark שומרת רק אסימון התחברות, שאפשר לבטל בכל עת.", "provider_title": "הסיסמה שלך נשארת אצל הספק", "qr_hint": "בהגדרות ← אבטחה ← הקשר יישום נייד מוצג קוד. כוון אליו את המצלמה.", @@ -532,6 +538,7 @@ "server_hint": "אותה כתובת שאתה מקליד בדפדפן. נוסיף בשבילך את https://.", "server_label": "כתובת השרת", "server_not_found": "לא מצאנו שרת עבור {domain}. אם אתה מארח בעצמך, הכתובת היא זו שבה אתה משתמש לדואר האינטרנט.", + "server_paste_hint": "יש לך קישור התחברות במקום זאת? הדבק אותו כאן.", "server_scan_hint": "אם אינך בטוח, סרוק במקום זאת קוד התחברות. הוא כולל את כתובת השרת.", "server_subtitle": "הזן את הכתובת שבה אתה ניגש לדואר האינטרנט.", "server_title": "מה כתובת השרת שלך?", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 59574448..11625133 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -522,6 +522,12 @@ "pairing_detail": "Az eszköz párosítása: {host}…", "password_subtitle": "A jelszavadat csak erre a szerverre küldjük el, és az eszköz kulcstárolójában tároljuk.", "password_title": "Bejelentkezés: {host}", + "paste_button": "Beillesztés", + "paste_clipboard_empty": "Nincs mit beilleszteni. Előbb másold ki a hivatkozást a webmailben.", + "paste_hint": "A webmailben nyisd meg a Beállítások → Biztonság → Mobilalkalmazás összekapcsolása menüt, másold ki a bejelentkezési hivatkozást, és illeszd be ide.", + "paste_invalid": "Ez nem Bulwark bejelentkezési hivatkozás", + "paste_label": "Bejelentkezési hivatkozás", + "paste_title": "Bejelentkezési hivatkozás beillesztése", "provider_detail": "A szolgáltató oldalán írod be. A Bulwark csak egy bejelentkezési tokent tárol, amelyet bármikor visszavonhatsz.", "provider_title": "A jelszavad a szolgáltatódnál marad", "qr_hint": "A Beállítások → Biztonság → Mobilalkalmazás összekapcsolása egy kódot jelenít meg. Irányítsd rá a kamerát.", @@ -532,6 +538,7 @@ "server_hint": "Ugyanaz a cím, amelyet a böngészőbe írsz. A https:// előtagot mi tesszük hozzá.", "server_label": "Szervercím", "server_not_found": "Nem találtunk szervert ehhez: {domain}. Ha saját szervert üzemeltetsz, a webmailhez használt címet add meg.", + "server_paste_hint": "Inkább bejelentkezési hivatkozásod van? Illeszd be ide.", "server_scan_hint": "Ha nem vagy biztos benne, olvass be inkább egy bejelentkezési kódot. Ez tartalmazza a szerver címét.", "server_subtitle": "Add meg a címet, amelyen a webmailt eléred.", "server_title": "Mi a szervered címe?", diff --git a/locales/rn/it.json b/locales/rn/it.json index 0bfe780e..1776a421 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -522,6 +522,12 @@ "pairing_detail": "Associazione di questo dispositivo con {host}…", "password_subtitle": "La password viene inviata solo a questo server e salvata nell'archivio sicuro del dispositivo.", "password_title": "Accedi a {host}", + "paste_button": "Incolla", + "paste_clipboard_empty": "Niente da incollare. Copia prima il link nella webmail.", + "paste_hint": "Nella webmail apri Impostazioni → Sicurezza → Collega l'app mobile, copia il link di accesso e incollalo qui.", + "paste_invalid": "Questo non è un link di accesso Bulwark", + "paste_label": "Link di accesso", + "paste_title": "Incolla link di accesso", "provider_detail": "La digiterai nella loro pagina. Bulwark salva solo un token di accesso, che puoi revocare in qualsiasi momento.", "provider_title": "La tua password resta al tuo provider", "qr_hint": "Impostazioni → Sicurezza → Collega l'app mobile mostra un codice. Inquadralo con la fotocamera.", @@ -532,6 +538,7 @@ "server_hint": "Lo stesso indirizzo che digiti nel browser. Aggiungeremo noi https://.", "server_label": "Indirizzo del server", "server_not_found": "Non abbiamo trovato un server per {domain}. Se lo gestisci tu, l'indirizzo è quello che usi per la webmail.", + "server_paste_hint": "Hai invece un link di accesso? Incollalo qui.", "server_scan_hint": "Se non sei sicuro, scansiona un codice di accesso: contiene l'indirizzo del server.", "server_subtitle": "Inserisci l'indirizzo che usi per raggiungere la tua webmail.", "server_title": "Qual è l'indirizzo del tuo server?", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 4aace21b..20a58c57 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -522,6 +522,12 @@ "pairing_detail": "このデバイスを{host}とペアリングしています…", "password_subtitle": "パスワードはこのサーバーにのみ送信され、デバイスのキーチェーンに保存されます。", "password_title": "{host}にサインイン", + "paste_button": "貼り付け", + "paste_clipboard_empty": "貼り付ける内容がありません。先にウェブメールでリンクをコピーしてください。", + "paste_hint": "ウェブメールで「設定 → セキュリティ → モバイルアプリを連携」を開き、サインインリンクをコピーしてここに貼り付けてください。", + "paste_invalid": "これは Bulwark のサインインリンクではありません", + "paste_label": "サインインリンク", + "paste_title": "サインインリンクを貼り付け", "provider_detail": "パスワードはプロバイダーのページで入力します。Bulwark が保存するのはサインイントークンのみで、いつでも取り消すことができます。", "provider_title": "パスワードはプロバイダーにのみ送信されます", "qr_hint": "「設定 → セキュリティ → モバイルアプリを連携」でコードが表示されます。カメラをコードに向けてください。", @@ -532,6 +538,7 @@ "server_hint": "ブラウザーで入力するものと同じアドレスです。https:// は自動で追加されます。", "server_label": "サーバーアドレス", "server_not_found": "{domain}のサーバーが見つかりませんでした。セルフホストしている場合は、ウェブメールで使用しているアドレスを入力してください。", + "server_paste_hint": "代わりにサインインリンクをお持ちですか?ここに貼り付けてください。", "server_scan_hint": "わからない場合は、代わりにサインインコードをスキャンしてください。コードにサーバーアドレスが含まれています。", "server_subtitle": "ウェブメールへのアクセスに使用しているアドレスを入力してください。", "server_title": "サーバーアドレスを入力してください", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index fa6751cf..5b66094c 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -522,6 +522,12 @@ "pairing_detail": "이 기기를 {host}와(과) 페어링하는 중…", "password_subtitle": "비밀번호는 이 서버로만 전송되며 기기의 키체인에 저장돼요.", "password_title": "{host}에 로그인", + "paste_button": "붙여넣기", + "paste_clipboard_empty": "붙여넣을 내용이 없어요. 먼저 웹메일에서 링크를 복사해 주세요.", + "paste_hint": "웹메일에서 설정 → 보안 → 모바일 앱 연결을 열고 로그인 링크를 복사해 여기에 붙여넣어 주세요.", + "paste_invalid": "Bulwark 로그인 링크가 아니에요", + "paste_label": "로그인 링크", + "paste_title": "로그인 링크 붙여넣기", "provider_detail": "비밀번호는 제공업체의 페이지에서 입력해요. Bulwark는 로그인 토큰만 저장하며, 언제든지 해지할 수 있어요.", "provider_title": "비밀번호는 제공업체에만 전달돼요", "qr_hint": "설정 → 보안 → 모바일 앱 연결에서 코드가 표시돼요. 카메라를 코드에 비춰 주세요.", @@ -532,6 +538,7 @@ "server_hint": "브라우저에 입력하는 주소와 같아요. https://는 자동으로 추가돼요.", "server_label": "서버 주소", "server_not_found": "{domain}의 서버를 찾을 수 없어요. 직접 호스팅하는 경우 웹메일에 사용하는 주소를 입력해 주세요.", + "server_paste_hint": "로그인 링크가 있나요? 여기에 붙여넣어 주세요.", "server_scan_hint": "잘 모르겠다면 대신 로그인 코드를 스캔해 주세요. 코드에 서버 주소가 포함되어 있어요.", "server_subtitle": "웹메일에 접속할 때 사용하는 주소를 입력해 주세요.", "server_title": "서버 주소가 무엇인가요?", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 75316359..2e5d2c77 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -522,6 +522,12 @@ "pairing_detail": "Šī ierīce tiek savienota pārī: {host}…", "password_subtitle": "Jūsu parole tiek nosūtīta tikai uz šo serveri un glabāta ierīces atslēgu krātuvē.", "password_title": "Pierakstīšanās: {host}", + "paste_button": "Ielīmēt", + "paste_clipboard_empty": "Nav ko ielīmēt. Vispirms nokopējiet saiti tīmekļa pastā.", + "paste_hint": "Tīmekļa pastā atveriet Iestatījumi → Drošība → Saistīt mobilo lietotni, nokopējiet pierakstīšanās saiti un ielīmējiet to šeit.", + "paste_invalid": "Šī nav Bulwark pierakstīšanās saite", + "paste_label": "Pierakstīšanās saite", + "paste_title": "Ielīmēt pierakstīšanās saiti", "provider_detail": "Jūs to ievadīsiet pakalpojumu sniedzēja lapā. Bulwark glabā tikai pierakstīšanās tokenu, ko varat jebkurā laikā atsaukt.", "provider_title": "Jūsu parole paliek pie pakalpojumu sniedzēja", "qr_hint": "Sadaļā Iestatījumi → Drošība → Saistīt mobilo lietotni tiek parādīts kods. Pavērsiet pret to kameru.", @@ -532,6 +538,7 @@ "server_hint": "Tā pati adrese, ko rakstāt pārlūkā. https:// pievienosim mēs.", "server_label": "Servera adrese", "server_not_found": "Mēs neatradām serveri domēnam {domain}. Ja izmantojat savu serveri, norādiet adresi, ko lietojat tīmekļa pastam.", + "server_paste_hint": "Jums ir pierakstīšanās saite? Ielīmējiet to šeit.", "server_scan_hint": "Ja šaubāties, tā vietā skenējiet pierakstīšanās kodu. Tajā ir servera adrese.", "server_subtitle": "Ievadiet adresi, ar kuru atverat savu tīmekļa pastu.", "server_title": "Kāda ir jūsu servera adrese?", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 6236220d..0f1fda37 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -522,6 +522,12 @@ "pairing_detail": "Энэ төхөөрөмжийг холбож байна: {host}…", "password_subtitle": "Таны нууц үгийг зөвхөн энэ сервер рүү илгээх бөгөөд төхөөрөмжийн түлхүүрийн санд хадгална.", "password_title": "Нэвтрэх: {host}", + "paste_button": "Буулгах", + "paste_clipboard_empty": "Буулгах зүйл алга. Эхлээд вэбмэйлээс холбоосыг хуулна уу.", + "paste_hint": "Вэбмэйлд Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох хэсгийг нээж, нэвтрэх холбоосыг хуулаад энд буулгана уу.", + "paste_invalid": "Энэ Bulwark-ийн нэвтрэх холбоос биш байна", + "paste_label": "Нэвтрэх холбоос", + "paste_title": "Нэвтрэх холбоос буулгах", "provider_detail": "Та нууц үгээ тэдний хуудсанд бичнэ. Bulwark зөвхөн нэвтрэх токен хадгалах бөгөөд та үүнийг хүссэн үедээ хүчингүй болгож болно.", "provider_title": "Таны нууц үг үйлчилгээ үзүүлэгч дээрээ үлдэнэ", "qr_hint": "Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох хэсэгт код гарна. Камераа түүн рүү чиглүүлнэ үү.", @@ -532,6 +538,7 @@ "server_hint": "Хөтөч дээр бичдэг хаяг. https://-г бид нэмнэ.", "server_label": "Серверийн хаяг", "server_not_found": "{domain} домэйны сервер олдсонгүй. Хэрэв та өөрөө сервертэй бол вэбмэйлд ашигладаг хаягаа оруулна уу.", + "server_paste_hint": "Оронд нь нэвтрэх холбоос байгаа юу? Энд буулгана уу.", "server_scan_hint": "Итгэлгүй байвал оронд нь нэвтрэх код скан хийнэ үү. Үүнд серверийн хаяг агуулагддаг.", "server_subtitle": "Вэбмэйл рүүгээ ороход ашигладаг хаягаа оруулна уу.", "server_title": "Таны серверийн хаяг юу вэ?", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 5a3af394..f8d7b79a 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -522,6 +522,12 @@ "pairing_detail": "Kobler denne enheten til {host}…", "password_subtitle": "Passordet ditt sendes bare til denne serveren og lagres i nøkkelringen på enheten.", "password_title": "Logg på {host}", + "paste_button": "Lim inn", + "paste_clipboard_empty": "Ingenting å lime inn. Kopier lenken i nettposten først.", + "paste_hint": "Åpne Innstillinger → Sikkerhet → Koble til mobilappen i nettposten, kopier påloggingslenken og lim den inn her.", + "paste_invalid": "Det er ikke en påloggingslenke for Bulwark", + "paste_label": "Påloggingslenke", + "paste_title": "Lim inn påloggingslenke", "provider_detail": "Du skriver det inn på deres side. Bulwark lagrer bare et påloggingstoken, som du når som helst kan tilbakekalle.", "provider_title": "Passordet ditt blir hos leverandøren", "qr_hint": "Innstillinger → Sikkerhet → Koble til mobilappen viser en kode. Rett kameraet mot den.", @@ -532,6 +538,7 @@ "server_hint": "Samme adresse som du skriver inn i nettleseren. Vi legger til https:// for deg.", "server_label": "Serveradresse", "server_not_found": "Vi fant ingen server for {domain}. Hvis du drifter din egen server, er det adressen du bruker for nettposten.", + "server_paste_hint": "Har du en påloggingslenke i stedet? Lim den inn her.", "server_scan_hint": "Hvis du er usikker, kan du skanne en påloggingskode i stedet. Den inneholder serveradressen.", "server_subtitle": "Skriv inn adressen du bruker for å nå nettposten din.", "server_title": "Hva er serveradressen din?", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 5aa8f1ec..f264fb2b 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -522,6 +522,12 @@ "pairing_detail": "Dit apparaat wordt gekoppeld aan {host}…", "password_subtitle": "Uw wachtwoord wordt alleen naar deze server verzonden en opgeslagen in de sleutelhanger van het apparaat.", "password_title": "Inloggen bij {host}", + "paste_button": "Plakken", + "paste_clipboard_empty": "Niets om te plakken. Kopieer eerst de link in de webmail.", + "paste_hint": "Open in de webmail Instellingen → Beveiliging → Mobiele app koppelen, kopieer de inloglink en plak die hier.", + "paste_invalid": "Dat is geen Bulwark-inloglink", + "paste_label": "Inloglink", + "paste_title": "Inloglink plakken", "provider_detail": "U typt het op hun pagina. Bulwark bewaart alleen een inlogtoken, dat u op elk moment kunt intrekken.", "provider_title": "Uw wachtwoord blijft bij uw provider", "qr_hint": "Instellingen → Beveiliging → Mobiele app koppelen toont een code. Richt de camera erop.", @@ -532,6 +538,7 @@ "server_hint": "Hetzelfde adres dat u in de browser typt. We voegen https:// voor u toe.", "server_label": "Serveradres", "server_not_found": "We konden geen server vinden voor {domain}. Als u zelf host, is het het adres dat u voor de webmail gebruikt.", + "server_paste_hint": "Hebt u in plaats daarvan een inloglink? Plak die hier.", "server_scan_hint": "Weet u het niet zeker? Scan dan een inlogcode. Die bevat het serveradres.", "server_subtitle": "Voer het adres in waarmee u uw webmail bereikt.", "server_title": "Wat is uw serveradres?", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index b71889cc..a45818d9 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -522,6 +522,12 @@ "pairing_detail": "Parowanie tego urządzenia: {host}…", "password_subtitle": "Hasło jest wysyłane tylko do tego serwera i przechowywane w magazynie kluczy urządzenia.", "password_title": "Zaloguj się do {host}", + "paste_button": "Wklej", + "paste_clipboard_empty": "Nie ma nic do wklejenia. Najpierw skopiuj link w webmailu.", + "paste_hint": "W webmailu otwórz Ustawienia → Bezpieczeństwo → Połącz aplikację mobilną, skopiuj link logowania i wklej go tutaj.", + "paste_invalid": "To nie jest link logowania Bulwark", + "paste_label": "Link logowania", + "paste_title": "Wklej link logowania", "provider_detail": "Wpiszesz je na stronie dostawcy. Bulwark przechowuje tylko token logowania, który możesz w każdej chwili unieważnić.", "provider_title": "Twoje hasło zostaje u dostawcy", "qr_hint": "W Ustawienia → Bezpieczeństwo → Połącz aplikację mobilną wyświetla się kod. Skieruj na niego aparat.", @@ -532,6 +538,7 @@ "server_hint": "Ten sam adres, który wpisujesz w przeglądarce. Dodamy https:// za Ciebie.", "server_label": "Adres serwera", "server_not_found": "Nie znaleźliśmy serwera dla {domain}. Jeśli masz własny serwer, podaj adres, którego używasz do webmaila.", + "server_paste_hint": "Masz zamiast tego link logowania? Wklej go tutaj.", "server_scan_hint": "Jeśli nie masz pewności, zeskanuj kod logowania. Zawiera on adres serwera.", "server_subtitle": "Wpisz adres, pod którym otwierasz webmail.", "server_title": "Jaki jest adres Twojego serwera?", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 62f782f4..7d9462ca 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -522,6 +522,12 @@ "pairing_detail": "Pareando este dispositivo com {host}…", "password_subtitle": "Sua senha é enviada apenas para este servidor e fica guardada no armazenamento seguro do dispositivo.", "password_title": "Entrar em {host}", + "paste_button": "Colar", + "paste_clipboard_empty": "Nada para colar. Copie o link no webmail primeiro.", + "paste_hint": "No webmail, abra Configurações → Segurança → Vincular aplicativo móvel, copie o link de login e cole aqui.", + "paste_invalid": "Isso não é um link de login do Bulwark", + "paste_label": "Link de login", + "paste_title": "Colar link de login", "provider_detail": "Você vai digitá-la na página deles. O Bulwark armazena apenas um token de login, que você pode revogar a qualquer momento.", "provider_title": "Sua senha fica com seu provedor", "qr_hint": "Configurações → Segurança → Vincular aplicativo móvel mostra um código. Aponte a câmera para ele.", @@ -532,6 +538,7 @@ "server_hint": "O mesmo endereço que você digita no navegador. Adicionamos o https:// para você.", "server_label": "Endereço do servidor", "server_not_found": "Não encontramos um servidor para {domain}. Se você hospeda o seu próprio, o endereço é o que você usa para o webmail.", + "server_paste_hint": "Tem um link de login? Cole aqui.", "server_scan_hint": "Se não tiver certeza, escaneie um código de login. Ele contém o endereço do servidor.", "server_subtitle": "Digite o endereço que você usa para acessar seu webmail.", "server_title": "Qual é o endereço do seu servidor?", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 9e3ab184..fd3c3984 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -522,6 +522,12 @@ "pairing_detail": "Se asociază acest dispozitiv cu {host}…", "password_subtitle": "Parola dvs. este trimisă doar către acest server și este stocată în spațiul securizat al dispozitivului.", "password_title": "Autentificare la {host}", + "paste_button": "Lipiți", + "paste_clipboard_empty": "Nu este nimic de lipit. Copiați mai întâi linkul din webmail.", + "paste_hint": "În webmail, deschideți Setări → Securitate → Aplicație mobilă, copiați linkul de autentificare și lipiți-l aici.", + "paste_invalid": "Acesta nu este un link de autentificare Bulwark", + "paste_label": "Link de autentificare", + "paste_title": "Lipiți linkul de autentificare", "provider_detail": "O veți introduce pe pagina lor. Bulwark stochează doar un token de autentificare, pe care îl puteți revoca oricând.", "provider_title": "Parola dvs. rămâne la furnizor", "qr_hint": "Setări → Securitate → Aplicație mobilă afișează un cod. Îndreptați camera spre el.", @@ -532,6 +538,7 @@ "server_hint": "Aceeași adresă pe care o introduceți în browser. Adăugăm noi https://.", "server_label": "Adresa serverului", "server_not_found": "Nu am găsit un server pentru {domain}. Dacă îl găzduiți chiar dvs., adresa este cea pe care o folosiți pentru webmail.", + "server_paste_hint": "Aveți în schimb un link de autentificare? Lipiți-l aici.", "server_scan_hint": "Dacă nu sunteți sigur, scanați în schimb un cod de autentificare. Acesta conține adresa serverului.", "server_subtitle": "Introduceți adresa pe care o folosiți pentru a accesa webmailul.", "server_title": "Care este adresa serverului dvs.?", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 9d3bba8b..052c7e2f 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -522,6 +522,12 @@ "pairing_detail": "Привязка устройства: {host}…", "password_subtitle": "Пароль отправляется только на этот сервер и хранится в защищённом хранилище устройства.", "password_title": "Вход на {host}", + "paste_button": "Вставить", + "paste_clipboard_empty": "Нечего вставлять. Сначала скопируйте ссылку в веб-почте.", + "paste_hint": "В веб-почте откройте Настройки → Безопасность → Привязать мобильное приложение, скопируйте ссылку для входа и вставьте её сюда.", + "paste_invalid": "Это не ссылка для входа Bulwark", + "paste_label": "Ссылка для входа", + "paste_title": "Вставить ссылку для входа", "provider_detail": "Вы введёте его на их странице. Bulwark хранит только токен входа, который можно отозвать в любой момент.", "provider_title": "Ваш пароль остаётся у провайдера", "qr_hint": "В разделе Настройки → Безопасность → Привязать мобильное приложение появится код. Наведите на него камеру.", @@ -532,6 +538,7 @@ "server_hint": "Тот же адрес, что вы вводите в браузере. Мы сами добавим https://.", "server_label": "Адрес сервера", "server_not_found": "Не удалось найти сервер для {domain}. Если у вас свой сервер, укажите адрес, по которому вы открываете веб-почту.", + "server_paste_hint": "У вас есть ссылка для входа? Вставьте её сюда.", "server_scan_hint": "Если не уверены, отсканируйте код входа — в нём есть адрес сервера.", "server_subtitle": "Введите адрес, по которому вы открываете веб-почту.", "server_title": "Какой адрес у вашего сервера?", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 673157d6..05631eeb 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -522,6 +522,12 @@ "pairing_detail": "Párovanie tohto zariadenia: {host}…", "password_subtitle": "Heslo sa odošle iba tomuto serveru a uloží sa do úložiska kľúčov v zariadení.", "password_title": "Prihlásiť sa do {host}", + "paste_button": "Vložiť", + "paste_clipboard_empty": "Nie je čo vložiť. Najprv skopírujte odkaz vo webmaile.", + "paste_hint": "Vo webmaile otvorte Nastavenia → Zabezpečenie → Prepojiť mobilnú aplikáciu, skopírujte prihlasovací odkaz a vložte ho sem.", + "paste_invalid": "Toto nie je prihlasovací odkaz Bulwark", + "paste_label": "Prihlasovací odkaz", + "paste_title": "Vložiť prihlasovací odkaz", "provider_detail": "Zadáte ho na jeho stránke. Bulwark ukladá iba prihlasovací token, ktorý môžete kedykoľvek odvolať.", "provider_title": "Vaše heslo zostáva u poskytovateľa", "qr_hint": "V Nastavenia → Zabezpečenie → Prepojiť mobilnú aplikáciu sa zobrazí kód. Namierte naň fotoaparát.", @@ -532,6 +538,7 @@ "server_hint": "Rovnaká adresa, akú zadávate v prehliadači. https:// doplníme za vás.", "server_label": "Adresa servera", "server_not_found": "Pre {domain} sme nenašli žiadny server. Ak prevádzkujete vlastný server, zadajte adresu, ktorú používate pre webmail.", + "server_paste_hint": "Máte namiesto toho prihlasovací odkaz? Vložte ho sem.", "server_scan_hint": "Ak si nie ste istí, naskenujte radšej prihlasovací kód. Obsahuje adresu servera.", "server_subtitle": "Zadajte adresu, cez ktorú otvárate webmail.", "server_title": "Aká je adresa vášho servera?", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 9952ae90..e38480cb 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -522,6 +522,12 @@ "pairing_detail": "Bu cihaz {host} ile eşleştiriliyor…", "password_subtitle": "Parolanız yalnızca bu sunucuya gönderilir ve cihazın anahtar deposunda saklanır.", "password_title": "Giriş yap: {host}", + "paste_button": "Yapıştır", + "paste_clipboard_empty": "Yapıştırılacak bir şey yok. Önce bağlantıyı webmail'de kopyalayın.", + "paste_hint": "Webmail'de Ayarlar → Güvenlik → Mobil uygulamayı bağla bölümünü açın, giriş bağlantısını kopyalayıp buraya yapıştırın.", + "paste_invalid": "Bu bir Bulwark giriş bağlantısı değil", + "paste_label": "Giriş bağlantısı", + "paste_title": "Giriş bağlantısını yapıştır", "provider_detail": "Parolanızı sağlayıcının sayfasında yazacaksınız. Bulwark yalnızca istediğiniz zaman iptal edebileceğiniz bir giriş belirteci saklar.", "provider_title": "Parolanız sağlayıcınızda kalır", "qr_hint": "Ayarlar → Güvenlik → Mobil uygulamayı bağla bir kod gösterir. Kamerayı ona doğrultun.", @@ -532,6 +538,7 @@ "server_hint": "Tarayıcıya yazdığınız adresin aynısı. https:// kısmını biz ekleriz.", "server_label": "Sunucu adresi", "server_not_found": "{domain} için bir sunucu bulamadık. Kendi sunucunuzu barındırıyorsanız webmail için kullandığınız adresi girin.", + "server_paste_hint": "Bunun yerine bir giriş bağlantınız mı var? Buraya yapıştırın.", "server_scan_hint": "Emin değilseniz bunun yerine bir giriş kodu tarayın. Kod, sunucu adresini içerir.", "server_subtitle": "Webmail'inize ulaşmak için kullandığınız adresi girin.", "server_title": "Sunucu adresiniz nedir?", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index a1861114..94aaffcf 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -522,6 +522,12 @@ "pairing_detail": "Прив'язка пристрою: {host}…", "password_subtitle": "Пароль надсилається лише на цей сервер і зберігається в захищеному сховищі пристрою.", "password_title": "Вхід на {host}", + "paste_button": "Вставити", + "paste_clipboard_empty": "Нічого вставляти. Спочатку скопіюйте посилання у веб-пошті.", + "paste_hint": "У веб-пошті відкрийте Налаштування → Безпека → Прив'язати мобільний застосунок, скопіюйте посилання для входу та вставте його сюди.", + "paste_invalid": "Це не посилання для входу Bulwark", + "paste_label": "Посилання для входу", + "paste_title": "Вставити посилання для входу", "provider_detail": "Ви введете його на їхній сторінці. Bulwark зберігає лише токен входу, який можна будь-коли відкликати.", "provider_title": "Ваш пароль залишається у провайдера", "qr_hint": "У розділі Налаштування → Безпека → Прив'язати мобільний застосунок з'явиться код. Наведіть на нього камеру.", @@ -532,6 +538,7 @@ "server_hint": "Та сама адреса, яку ви вводите в браузері. Ми самі додамо https://.", "server_label": "Адреса сервера", "server_not_found": "Не вдалося знайти сервер для {domain}. Якщо у вас власний сервер, вкажіть адресу, за якою ви відкриваєте веб-пошту.", + "server_paste_hint": "Маєте посилання для входу? Вставте його сюди.", "server_scan_hint": "Якщо не впевнені, відскануйте код входу — він містить адресу сервера.", "server_subtitle": "Введіть адресу, за якою ви відкриваєте веб-пошту.", "server_title": "Яка адреса вашого сервера?", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 6028536a..9be70b84 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -522,6 +522,12 @@ "pairing_detail": "正在將此裝置與 {host} 配對…", "password_subtitle": "您的密碼只會傳送到此伺服器,並儲存在裝置的鑰匙圈中。", "password_title": "登入 {host}", + "paste_button": "貼上", + "paste_clipboard_empty": "沒有可貼上的內容。請先在網頁郵件中複製連結。", + "paste_hint": "在網頁郵件中開啟「設定 → 安全 → 連結行動應用程式」,複製登入連結並貼到這裡。", + "paste_invalid": "這不是 Bulwark 登入連結", + "paste_label": "登入連結", + "paste_title": "貼上登入連結", "provider_detail": "您會在服務供應商的頁面上輸入密碼。Bulwark 只會儲存登入權杖,您可以隨時撤銷。", "provider_title": "您的密碼只會留在服務供應商端", "qr_hint": "「設定 → 安全 → 連結行動應用程式」會顯示代碼。請將相機對準代碼。", @@ -532,6 +538,7 @@ "server_hint": "與您在瀏覽器中輸入的地址相同。我們會自動加上 https://。", "server_label": "伺服器地址", "server_not_found": "找不到 {domain} 的伺服器。如果您自行架設,請使用開啟網頁郵件時的地址。", + "server_paste_hint": "有登入連結嗎?請貼到這裡。", "server_scan_hint": "如果不確定,請改為掃描登入碼。登入碼中包含伺服器地址。", "server_subtitle": "請輸入您開啟網頁郵件時使用的地址。", "server_title": "您的伺服器地址是什麼?", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 0997bec6..2a616106 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -522,6 +522,12 @@ "pairing_detail": "正在将此设备与 {host} 配对…", "password_subtitle": "您的密码只会发送到此服务器,并存储在设备的钥匙串中。", "password_title": "登录 {host}", + "paste_button": "粘贴", + "paste_clipboard_empty": "没有可粘贴的内容。请先在网页邮箱中复制链接。", + "paste_hint": "在网页邮箱中打开“设置 → 安全 → 关联移动应用”,复制登录链接并粘贴到这里。", + "paste_invalid": "这不是 Bulwark 登录链接", + "paste_label": "登录链接", + "paste_title": "粘贴登录链接", "provider_detail": "您将在服务提供商的页面上输入密码。Bulwark 只存储登录令牌,您可以随时撤销。", "provider_title": "您的密码只保留在服务提供商处", "qr_hint": "“设置 → 安全 → 关联移动应用”中会显示一个二维码。请将相机对准它。", @@ -532,6 +538,7 @@ "server_hint": "即您在浏览器中输入的地址。我们会自动为您添加 https://。", "server_label": "服务器地址", "server_not_found": "找不到 {domain} 的服务器。如果您自行托管,请使用访问网页邮箱时的地址。", + "server_paste_hint": "有登录链接?请粘贴到这里。", "server_scan_hint": "如果不确定,请改为扫描登录码。登录码中包含服务器地址。", "server_subtitle": "请输入您访问网页邮箱时使用的地址。", "server_title": "您的服务器地址是什么?", diff --git a/src/components/PasteSignInLinkModal.tsx b/src/components/PasteSignInLinkModal.tsx new file mode 100644 index 00000000..403fd964 --- /dev/null +++ b/src/components/PasteSignInLinkModal.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { + View, + Text, + TextInput, + Pressable, + StyleSheet, + Modal, + Platform, + KeyboardAvoidingView, + ScrollView, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import * as Clipboard from 'expo-clipboard'; +import { ClipboardPaste, X } from 'lucide-react-native'; +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'; + +interface PasteSignInLinkModalProps { + visible: boolean; + onClose: () => void; + // Fires with a link that parsed; the parent closes the modal and signs in + // with it exactly as with a scanned code. + onSubmit: (payload: QrLoginPayload) => void; +} + +/** + * For when the code can't be scanned (a phone showing the webmail itself, a + * broken camera): the same `bulwarkmail://` link, pasted as text. + */ +export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignInLinkModalProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((st) => st.t); + const [text, setText] = React.useState(''); + const [error, setError] = React.useState(null); + const [focused, setFocused] = React.useState(false); + + React.useEffect(() => { + if (!visible) return; + setText(''); + setError(null); + }, [visible]); + + const paste = React.useCallback(async () => { + let value = ''; + try { + value = await Clipboard.getStringAsync(); + } catch { + value = ''; + } + if (!value.trim()) { + setError(t('login.mobile.paste_clipboard_empty', 'Nothing to paste. Copy the link in the webmail first.')); + return; + } + setText(value.trim()); + setError(null); + }, [t]); + + const submit = React.useCallback(() => { + const payload = parsePastedSignInLink(text); + if (!payload) { + setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link")); + return; + } + onSubmit(payload); + }, [onSubmit, t, text]); + + const title = t('login.mobile.paste_title', 'Paste sign-in link'); + + return ( + + + + + + + + {title} + + + + + + {t('login.mobile.paste_hint', 'In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.')} + + + { + setText(value); + setError(null); + }} + placeholder="bulwarkmail://pair?server=…&code=…" + placeholderTextColor={c.textMuted} + accessibilityLabel={t('login.mobile.paste_label', 'Sign-in link')} + multiline + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + textAlignVertical="top" + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + style={[styles.input, focused && styles.inputFocused, error ? styles.inputError : null]} + /> + + {error ? ( + {error} + ) : null} + + + + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: c.background }, + flex: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm, + paddingBottom: spacing.md, + }, + closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + title: { ...typography.h3, color: c.text }, + content: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xxxl, gap: spacing.lg }, + hint: { ...typography.body, color: c.textSecondary }, + input: { + ...typography.body, + color: c.text, + minHeight: 96, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + backgroundColor: c.background, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + }, + inputFocused: { borderColor: c.borderFocus, borderWidth: 2 }, + inputError: { borderColor: c.error }, + error: { ...typography.caption, color: c.error, marginTop: -spacing.sm }, + }); +} diff --git a/src/components/__tests__/screens-i18n.test.ts b/src/components/__tests__/screens-i18n.test.ts index 4be30f12..8bc4791c 100644 --- a/src/components/__tests__/screens-i18n.test.ts +++ b/src/components/__tests__/screens-i18n.test.ts @@ -41,6 +41,8 @@ const TRANSLATED_FILES: Record = { 'components/TemplateSheet.tsx': [], 'components/IdentitySheet.tsx': [], 'components/QrScanModal.tsx': [], + // The input's placeholder is the link's own format. + 'components/PasteSignInLinkModal.tsx': ['bulwarkmail://pair?server=…&code=…'], 'components/email/ActionSheet.tsx': [], 'components/email/AddressActionSheet.tsx': [], 'components/email/CalendarInvitationBanner.tsx': [], diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 3468282b..363a3cfa 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking'; +import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking'; +import { setPendingSignInLink, usePendingSignInLinkStore } from '../pending-sign-in-link'; import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingCalendarOpen } from '../pending-calendar-open'; import { usePendingMailSearch } from '../pending-mail-search'; @@ -257,3 +258,44 @@ describe('shareToDeepLink', () => { }); }); }); + +describe('sign-in links', () => { + const code = 'c0ffee'.repeat(10) + 'beef'; + const pair = `bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com/webmail')}&code=${code}`; + + it('are never navigation links', () => { + expect(parseDeepLink(pair)).toBeNull(); + expect(parseDeepLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBeNull(); + // The scheme is not an alias of the app scheme. + expect(parseDeepLink('bulwarkmail://mail/message/M1')).toBeNull(); + }); + + it('parses pair and connect links, and nothing else', () => { + expect(parseSignInLink(pair)).toEqual({ kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code }); + expect(parseSignInLink(pair.replace('://pair?', '://pair/?'))).toEqual({ + kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code, + }); + expect(parseSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')) + .toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' }); + // A plain webmail address is a QR convenience, not a link that signs in. + expect(parseSignInLink('https://mail.example.com')).toBeNull(); + expect(parseSignInLink('bulwarkmobile://mail/message/M1')).toBeNull(); + expect(parseSignInLink(`bulwarkmail://pair?server=http%3A%2F%2Fmail.example.com&code=${code}`)).toBeNull(); + expect(parseSignInLink(null)).toBeNull(); + }); + + it('parks a sign-in link for the login screen, which takes it once', () => { + setPendingSignInLink(null); + expect(acceptSignInLink('bulwarkmobile://mail')).toBe(false); + expect(acceptSignInLink('mailto:a@b.co')).toBe(false); + expect(usePendingSignInLinkStore.getState().payload).toBeNull(); + + expect(acceptSignInLink(pair)).toBe(true); + expect(usePendingSignInLinkStore.getState().take()).toEqual({ + 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().take()).toBeNull(); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 537b48e0..f45af146 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -9,8 +9,14 @@ 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'; export const APP_SCHEME = 'bulwarkmobile'; +// The webmail's "Link Mobile App" links (`bulwarkmail://pair?server=…&code=…`) +// and server-bootstrap links (`bulwarkmail://connect?server=…`). They sign in +// rather than navigate, so they never become a DeepLink. +export const SIGN_IN_SCHEME = 'bulwarkmail'; const UNIFIED_ROLES = ['inbox', 'sent', 'drafts', 'junk', 'archive', 'trash'] as const; const UNIFIED_VIEWS = ['all', 'unread', 'starred'] as const; @@ -74,6 +80,7 @@ function splitUrl(url: string): { segments: string[]; search: URLSearchParams } export function parseDeepLink(url: string): DeepLink | null { if (!url) return null; + if (isSignInSchemeUrl(url)) return null; if (/^mailto:/i.test(url)) { const parsed = parseMailtoUrl(url); if (!parsed) return null; @@ -166,6 +173,28 @@ export function parseDeepLink(url: string): DeepLink | null { } } +function isSignInSchemeUrl(url: string): boolean { + return url.trim().toLowerCase().startsWith(`${SIGN_IN_SCHEME}:`); +} + +/** A `bulwarkmail://pair|connect?…` link, parsed; null for any other URL. */ +export function parseSignInLink(url: string | null | undefined): QrLoginPayload | null { + if (!url || !isSignInSchemeUrl(url)) return null; + return parseQrLoginPayload(url); +} + +/** + * 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. + */ +export function acceptSignInLink(url: string | null | undefined): boolean { + const payload = parseSignInLink(url); + if (!payload) return false; + setPendingSignInLink(payload); + return true; +} + 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 new file mode 100644 index 00000000..72046404 --- /dev/null +++ b/src/navigation/pending-sign-in-link.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import type { 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; +} + +export const usePendingSignInLinkStore = create((set, get) => ({ + payload: null, + set: (payload) => set({ payload }), + take: () => { + const payload = get().payload; + if (payload) set({ payload: null }); + return payload; + }, +})); + +export function setPendingSignInLink(payload: QrLoginPayload | null): void { + usePendingSignInLinkStore.getState().set(payload); +} diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 12d28cae..49a87a34 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -5,7 +5,9 @@ import { useAccountStore } from '../stores/account-store'; import { useLocaleStore } from '../stores/locale-store'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { QrScanModal } from '../components/QrScanModal'; -import { parseQrLoginPayload } from '../lib/oauth'; +import { PasteSignInLinkModal } from '../components/PasteSignInLinkModal'; +import { parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; +import { usePendingSignInLinkStore } from '../navigation/pending-sign-in-link'; import { discoverServerForEmail, emailDomain, @@ -69,6 +71,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo const [searching, setSearching] = React.useState(false); const [busy, setBusy] = React.useState(null); const [scannerVisible, setScannerVisible] = React.useState(false); + const [pasteVisible, setPasteVisible] = React.useState(false); + // The webmail a sign-in code is being redeemed at, for the waiting screen + // (in add mode the server fields still name the signed-in account's host). + const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState(null); // Addresses typed before on this device (last 5) plus the registry's, so a // returning user taps instead of retyping. Never contains passwords. @@ -131,6 +137,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo setScannerVisible(false); return true; } + if (pasteVisible) { + setPasteVisible(false); + return true; + } if (history.length > 0) { goBack(); return true; @@ -142,7 +152,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo return false; }); return () => subscription.remove(); - }, [busy, searching, scannerVisible, history.length, goBack, isAddMode, onCancel]); + }, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel]); // ── flows ────────────────────────────────────────────── @@ -160,6 +170,27 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo [isAddMode, onLogin], ); + // The webmail hands back the password even when the account needs a second + // factor (a hand-off, or a sign-in code on a server without app passwords); + // finish the sign-in here with a code instead of failing (the user already + // proved the password once). Returns whether it took over. + const continueWithSecondFactor = React.useCallback( + (err: unknown, target: string): boolean => { + const pending = useAuthStore.getState().pendingTotpLogin; + if (!(err instanceof Error && err.name === 'TotpRequiredError' && pending)) return false; + setEmail(pending.username); + setPassword(pending.password); + setServerUrl(pending.serverUrl); + setTotpRequired(true); + setBusy(null); + setNotice(describeLoginError(err, { serverUrl: target, t })); + setHistory((prev) => [...prev, step]); + setStep('password'); + return true; + }, + [step, t], + ); + const runHandoff = React.useCallback( async (target: string) => { setNotice(null); @@ -171,27 +202,13 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // that's a cancellation, not a failure, so leave them where they were. finishIfSignedIn(wasAuthenticated); } catch (err) { - // The webmail hands back the password even when the account needs a - // second factor; finish the sign-in here with a code instead of - // failing (the user already proved the password once). - const pending = useAuthStore.getState().pendingTotpLogin; - if (err instanceof Error && err.name === 'TotpRequiredError' && pending) { - setEmail(pending.username); - setPassword(pending.password); - setServerUrl(pending.serverUrl); - setTotpRequired(true); - setBusy(null); - setNotice(describeLoginError(err, { serverUrl: target, t })); - setHistory((prev) => [...prev, step]); - setStep('password'); - return; - } + if (continueWithSecondFactor(err, target)) return; setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { setBusy(null); } }, - [finishIfSignedIn, isAddMode, loginViaWebmail, step, t], + [continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaWebmail, t], ); const handleEmailContinue = React.useCallback(async () => { @@ -275,17 +292,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]); - const handleScanned = React.useCallback( - async (data: string) => { + // A sign-in code, however it arrived: scanned, pasted, or a tapped + // `bulwarkmail://` link. + const handleSignInLink = React.useCallback( + async (payload: QrLoginPayload) => { setScannerVisible(false); - const payload = parseQrLoginPayload(data); - if (!payload) { - 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.'), - }); - return; - } + setPasteVisible(false); if (payload.kind === 'connect') { // Server-bootstrap code: it carries the address, the user still @@ -296,34 +308,80 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo return; } - // Cross-device pairing code: redeem it for tokens, no browser needed. + // Cross-device pairing code: redeem it, no browser needed. setNotice(null); + setPairingWebmailUrl(payload.webmailUrl); setBusy('pairing'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); finishIfSignedIn(wasAuthenticated); } catch (err) { + if (continueWithSecondFactor(err, payload.webmailUrl)) return; + // The error names its own host: the webmail for a failed redeem, the + // mail server when connecting afterwards failed. setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t })); } finally { setBusy(null); + setPairingWebmailUrl(null); } }, - [finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t], + [continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t], ); + const handleScanned = React.useCallback( + (data: string) => { + setScannerVisible(false); + const payload = parseQrLoginPayload(data); + if (!payload) { + 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.'), + }); + return; + } + void handleSignInLink(payload); + }, + [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); + React.useEffect(() => { + if (!signInLinkPending || busy || searching) return; + const payload = usePendingSignInLinkStore.getState().take(); + if (payload) void handleSignInLink(payload); + }, [busy, handleSignInLink, searching, signInLinkPending]); + // ── render ───────────────────────────────────────────── if (busy) { + const pairing = busy === 'pairing'; return ( ); } + const modals = ( + <> + setScannerVisible(false)} + onScanned={handleScanned} + /> + setPasteVisible(false)} + onSubmit={(payload) => void handleSignInLink(payload)} + /> + + ); + const canGoBack = history.length > 0; const shellProps = { onBack: canGoBack ? goBack : undefined, @@ -344,6 +402,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo knownServerUrl={isAddMode ? knownServerUrl : null} notice={chooseNotice} onScan={() => setScannerVisible(true)} + onPaste={() => setPasteVisible(true)} onUseEmail={() => goTo('email')} onUseKnownServer={() => { if (!knownServerUrl) return; @@ -358,11 +417,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo }} /> - setScannerVisible(false)} - onScanned={(data) => void handleScanned(data)} - /> + {modals} ); } @@ -398,6 +453,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo }} onSubmit={handleServerContinue} onScan={() => setScannerVisible(true)} + onPaste={() => setPasteVisible(true)} failedDomain={failedDomain} notice={notice} /> @@ -442,11 +498,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo ) : null} - setScannerVisible(false)} - onScanned={(data) => void handleScanned(data)} - /> + {modals} ); } diff --git a/src/screens/login/ChooseStep.tsx b/src/screens/login/ChooseStep.tsx index fac602aa..b5f9b042 100644 --- a/src/screens/login/ChooseStep.tsx +++ b/src/screens/login/ChooseStep.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, Pressable, StyleSheet, Image } from 'react-native'; -import { QrCode, Mail, Plus, Server } from 'lucide-react-native'; +import { ClipboardPaste, QrCode, Mail, Plus, Server } from 'lucide-react-native'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors, useResolvedTheme } from '../../theme/colors'; import type { AccountEntry } from '../../stores/account-store'; @@ -19,6 +19,8 @@ interface ChooseStepProps { /** Host of the signed-in account, offered as a shortcut in add mode. */ knownServerUrl: string | null; onScan: () => void; + /** Paste the sign-in link instead of scanning its code. */ + onPaste: () => void; onUseEmail: () => void; onUseKnownServer: () => void; onManualSetup: () => void; @@ -46,6 +48,7 @@ export default function ChooseStep({ accounts, knownServerUrl, onScan, + onPaste, onUseEmail, onUseKnownServer, onManualSetup, @@ -57,6 +60,16 @@ export default function ChooseStep({ const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + // Sits right under the scan tile, wherever that is. + const pasteTile = ( + } + onPress={onPaste} + disabled={disabled} + /> + ); + return ( {isAddMode ? ( @@ -115,14 +128,17 @@ export default function ChooseStep({ disabled={disabled} /> ) : ( - } - onPress={onScan} - disabled={disabled} - /> + <> + } + onPress={onScan} + disabled={disabled} + /> + {pasteTile} + )} {isAddMode && knownServerUrl ? ( - } - onPress={onScan} - disabled={disabled} - /> + <> + } + onPress={onScan} + disabled={disabled} + /> + {pasteTile} + ) : null} diff --git a/src/screens/login/ServerStep.tsx b/src/screens/login/ServerStep.tsx index 4fe74a3b..aadae708 100644 --- a/src/screens/login/ServerStep.tsx +++ b/src/screens/login/ServerStep.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, Pressable, StyleSheet } from 'react-native'; -import { Globe, QrCode } from 'lucide-react-native'; +import { ClipboardPaste, Globe, QrCode } from 'lucide-react-native'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { Button, Input } from '../../components'; @@ -12,6 +12,7 @@ interface ServerStepProps { onChange: (value: string) => void; onSubmit: () => void; onScan: () => void; + onPaste: () => void; /** Set when we got here because discovery came back empty for this domain. */ failedDomain?: string | null; notice?: { title: string; detail?: string } | null; @@ -27,6 +28,7 @@ export default function ServerStep({ onChange, onSubmit, onScan, + onPaste, failedDomain, notice, }: ServerStepProps) { @@ -67,12 +69,20 @@ export default function ServerStep({ {t('login.mobile.continue', 'Continue')} - - - - {t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")} - - + + + + + {t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")} + + + + + + {t('login.mobile.server_paste_hint', 'Have a sign-in link instead? Paste it here.')} + + + ); }