mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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": "ما عنوان خادمك؟",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "نشانی سرور شما چیست؟",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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": "מה כתובת השרת שלך?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "サーバーアドレスを入力してください",
|
||||
|
||||
@@ -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": "서버 주소가 무엇인가요?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "Таны серверийн хаяг юу вэ?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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.?",
|
||||
|
||||
@@ -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": "Какой адрес у вашего сервера?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "Яка адреса вашого сервера?",
|
||||
|
||||
@@ -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": "您的伺服器地址是什麼?",
|
||||
|
||||
@@ -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": "您的服务器地址是什么?",
|
||||
|
||||
@@ -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': [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user