feat: open sign-in links from the camera app and paste them into the app

The webmail's QR encodes a `bulwarkmail://pair?...` link, but nothing
registered that scheme: scanning it with the system camera or Google
Lens did nothing, and without a camera there was no way in at all.

- `bulwarkmail` is registered in `app.config.js` (iOS) and the committed
  AndroidManifest. Links are parked in a small store and picked up by
  the sign-in screen, or by "Add account" when already signed in, once
  per launch.
- "Paste sign-in link" on the sign-in screen (and next to the server
  step's scan hint) takes a link copied from the webmail.
- While pairing, the waiting screen names the webmail being paired with.
This commit is contained in:
Linus Rath
2026-09-27 04:23:01 +02:00
parent 59cfe50793
commit 1131073323
38 changed files with 660 additions and 68 deletions
+40 -1
View File
@@ -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<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
// 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<void> {
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
+7
View File
@@ -28,6 +28,13 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
<!-- Sign-in links from the webmail (bulwarkmail://pair?..., bulwarkmail://connect?...). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmail"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
+4 -2
View File
@@ -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',
+7
View File
@@ -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": "ما عنوان خادمك؟",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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": "نشانی سرور شما چیست؟",
+7
View File
@@ -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 ?",
+7
View File
@@ -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": "מה כתובת השרת שלך?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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": "サーバーアドレスを入力してください",
+7
View File
@@ -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": "서버 주소가 무엇인가요?",
+7
View File
@@ -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?",
+7
View File
@@ -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": "Таны серверийн хаяг юу вэ?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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.?",
+7
View File
@@ -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": "Какой адрес у вашего сервера?",
+7
View File
@@ -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?",
+7
View File
@@ -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?",
+7
View File
@@ -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": "Яка адреса вашого сервера?",
+7
View File
@@ -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": "您的伺服器地址是什麼?",
+7
View File
@@ -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": "您的服务器地址是什么?",
+175
View File
@@ -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<string | null>(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 (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
<Pressable
onPress={onClose}
hitSlop={10}
style={styles.closeButton}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={24} color={c.text} />
</Pressable>
<Text style={styles.title} accessibilityRole="header">{title}</Text>
<View style={styles.closeButton} />
</View>
<ScrollView
style={styles.flex}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.hint}>
{t('login.mobile.paste_hint', 'In the webmail, open Settings → Security → Link Mobile App, copy the sign-in link and paste it here.')}
</Text>
<TextInput
value={text}
onChangeText={(value) => {
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 ? (
<Text style={styles.error} accessibilityLiveRegion="polite">{error}</Text>
) : null}
<Button
variant="outline"
size="md"
onPress={() => void paste()}
icon={<ClipboardPaste size={16} color={c.text} />}
>
{t('login.mobile.paste_button', 'Paste')}
</Button>
<Button variant="default" size="md" onPress={submit} disabled={!text.trim()}>
{t('login.sign_in', 'Sign in')}
</Button>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
);
}
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 },
});
}
@@ -41,6 +41,8 @@ const TRANSLATED_FILES: Record<string, string[]> = {
'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': [],
+43 -1
View File
@@ -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();
});
});
+29
View File
@@ -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<RootStackParamList>;
// Resolve a message id to its thread (EmailThread needs both). Returns
+26
View File
@@ -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<PendingSignInLinkState>((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);
}
+94 -42
View File
@@ -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<SigningInPhase | null>(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<string | null>(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 (
<SigningInStep
phase={busy}
serverUrl={serverUrl || knownServerUrl}
email={email.trim() || null}
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
email={pairing ? null : email.trim() || null}
/>
);
}
const modals = (
<>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={handleScanned}
/>
<PasteSignInLinkModal
visible={pasteVisible}
onClose={() => 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
}}
/>
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => 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}
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => void handleScanned(data)}
/>
{modals}
</>
);
}
+34 -15
View File
@@ -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 = (
<OptionTile
title={t('login.mobile.paste_title', 'Paste sign-in link')}
renderIcon={(color, size) => <ClipboardPaste size={size} color={color} />}
onPress={onPaste}
disabled={disabled}
/>
);
return (
<View style={styles.root}>
{isAddMode ? (
@@ -115,14 +128,17 @@ export default function ChooseStep({
disabled={disabled}
/>
) : (
<OptionTile
emphasis="primary"
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
description={t('login.mobile.scan_desc', "The fastest way in if you're signed in on the web")}
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
<>
<OptionTile
emphasis="primary"
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
description={t('login.mobile.scan_desc', "The fastest way in if you're signed in on the web")}
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
{pasteTile}
</>
)}
<OptionTile
@@ -134,12 +150,15 @@ export default function ChooseStep({
/>
{isAddMode && knownServerUrl ? (
<OptionTile
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
<>
<OptionTile
title={t('login.mobile.scan_title', 'Scan a sign-in code')}
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
{pasteTile}
</>
) : null}
</View>
+17 -7
View File
@@ -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')}
</Button>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")}
</Text>
</Pressable>
<View>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow} accessibilityRole="button">
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_scan_hint', "If you're not sure, scan a sign-in code instead. It carries the server address.")}
</Text>
</Pressable>
<Pressable onPress={onPaste} hitSlop={8} style={styles.scanRow} accessibilityRole="button">
<ClipboardPaste size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_paste_hint', 'Have a sign-in link instead? Paste it here.')}
</Text>
</Pressable>
</View>
</View>
);
}