mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724c3b80ee | ||
|
|
a27d014c59 | ||
|
|
26bb947940 | ||
|
|
1131073323 | ||
|
|
59cfe50793 | ||
|
|
db07acc37c | ||
|
|
39628d84dc | ||
|
|
78901f2375 | ||
|
|
a4985a63a8 |
@@ -58,10 +58,11 @@ import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
||||
import { prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { toast } from './src/stores/toast-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
@@ -69,11 +70,19 @@ import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||
import { ToastHost } from './src/components/ToastHost';
|
||||
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
||||
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||
import { getEmails, getFullEmail } from './src/api/email';
|
||||
import { getEmails } from './src/api/email';
|
||||
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||
import { replyComposeParams } from './src/lib/reply-compose';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||
import {
|
||||
acceptSignInLink,
|
||||
handleDeepLink,
|
||||
parseDeepLink,
|
||||
routeParkedSignInLink,
|
||||
shareToDeepLink,
|
||||
type DeepLink,
|
||||
} from './src/navigation/linking';
|
||||
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
||||
import { MAX_ACCOUNTS } from './src/lib/account-utils';
|
||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
@@ -86,6 +95,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;
|
||||
|
||||
@@ -134,7 +147,7 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise<voi
|
||||
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
||||
// Android share-sheet payloads all end up here once the navigator is ready.
|
||||
async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
await handleDeepLink(link, {
|
||||
const opened = await handleDeepLink(link, {
|
||||
navigation: navigationRef,
|
||||
resolveThreadId: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
@@ -144,19 +157,10 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
openReply: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const params = replyComposeParams('reply', await getFullEmail(emailId, jmapAccountId), jmapAccountId);
|
||||
if (!params || !navigationRef.isReady()) return false;
|
||||
navigationRef.navigate('Compose', params);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
openDraft: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const draft = draftContextFromEmail(await getFullEmail(emailId, jmapAccountId), jmapAccountId);
|
||||
// Cache first, so a draft read before still opens offline.
|
||||
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
|
||||
if (!navigationRef.isReady()) return false;
|
||||
navigationRef.navigate('Compose', { draft });
|
||||
return true;
|
||||
@@ -172,6 +176,9 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
return useAuthStore.getState().activeAccountId === accountId;
|
||||
},
|
||||
});
|
||||
// A widget or a link elsewhere brought the app up for this; say it failed
|
||||
// rather than leave the user wondering why the app just opened.
|
||||
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
|
||||
}
|
||||
|
||||
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
||||
@@ -572,6 +579,52 @@ 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), marked as coming from
|
||||
// outside the app: any page or app can fire one, so the login screen asks
|
||||
// before it redeems or opens anything.
|
||||
React.useEffect(() => {
|
||||
if (!initialSignInLinkRead) {
|
||||
initialSignInLinkRead = true;
|
||||
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 (or says why a refused one won't be used).
|
||||
// On a cold start the navigator mounts right after the auth gate flips, so
|
||||
// wait for it like a reminder tap does.
|
||||
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
|
||||
const addAccountForLink = isAuthenticated && signInLinkPending;
|
||||
React.useEffect(() => {
|
||||
if (!addAccountForLink) return;
|
||||
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;
|
||||
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
|
||||
if (route === 'account-limit') {
|
||||
// Said like the Add account buttons say it, instead of opening a
|
||||
// screen whose sign-in can only fail.
|
||||
const t = useLocaleStore.getState().t;
|
||||
toast.error(
|
||||
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
|
||||
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (route === 'add-account') navigationRef.navigate('AddAccount');
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [addAccountForLink]);
|
||||
|
||||
// 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"/>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/widget_frame" />
|
||||
<corners android:radius="@dimen/widget_radius" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_surface" />
|
||||
<corners android:radius="@dimen/widget_radius" />
|
||||
</shape>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_surface"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/widget_frame"
|
||||
tools:targetApi="31">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_light"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_dark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="visible" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_clickable_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_collection_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
|
||||
layout-night/rn_widget.xml is its night twin).
|
||||
|
||||
The library draws each widget into one bitmap sized from the size the
|
||||
launcher reports, and pins it to the top left without scaling. When the real
|
||||
frame is smaller (after a rotation, or on a launcher that over-reports), the
|
||||
bitmap's bottom edge is cut off and takes the rounded corners and the border
|
||||
with it, so the bottom corners fall back to whatever the launcher clips to.
|
||||
This root always has the real size, so it owns the shape instead: it clips to
|
||||
a rounded outline (Android 12+; the launcher then leaves the corners to us),
|
||||
fills in where the bitmap falls short, and draws the frame on top.
|
||||
-->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_surface"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/widget_frame"
|
||||
tools:targetApi="31">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_light"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_dark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="gone" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_clickable_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_collection_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
@@ -3,4 +3,6 @@
|
||||
<color name="splashscreen_background">#09090b</color>
|
||||
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#09090b</color>
|
||||
<!-- DARK.bg in src/widgets/theme.ts. -->
|
||||
<color name="widget_surface">#0a0a0a</color>
|
||||
</resources>
|
||||
@@ -6,4 +6,8 @@
|
||||
<color name="colorPrimaryDark">#09090b</color>
|
||||
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#ffffff</color>
|
||||
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
|
||||
<color name="widget_surface">#ffffff</color>
|
||||
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
|
||||
<color name="widget_frame">#4d808080</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<dimen name="widget_radius">22dp</dimen>
|
||||
</resources>
|
||||
+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',
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "ما عنوان بريدك الإلكتروني؟",
|
||||
"email_step_title_add": "ما العنوان الآخر؟",
|
||||
"email_title": "استخدام عنوان بريدي الإلكتروني",
|
||||
"err_account_limit_detail": "أزِل حسابًا من الإعدادات لإضافة حساب آخر.",
|
||||
"err_bad_detail": "تحقق من بريدك الإلكتروني وكلمة المرور. إذا كان حسابك يستخدم تسجيل الدخول بخطوتين، فأنشئ كلمة مرور تطبيق في بريد الويب واستخدمها هنا.",
|
||||
"err_bad_title": "لم ينجح ذلك",
|
||||
"err_cert_detail": "تم رفض شهادة الأمان الخاصة بالخادم. إذا كان هذا خادمك الخاص، فتحقق من أن الشهادة صالحة وغير منتهية الصلاحية.",
|
||||
"err_cert_title": "تعذّر التحقق من {host}",
|
||||
"err_code_expired_detail": "رموز تسجيل الدخول صالحة لبضع دقائق فقط. أنشئ رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
|
||||
"err_code_expired_detail": "رموز تسجيل الدخول صالحة لمدة دقيقتين. اعرض رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
|
||||
"err_code_expired_or_used_detail": "اعرض رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
|
||||
"err_code_expired_or_used_title": "انتهت صلاحية هذا الرمز أو تم استخدامه من قبل",
|
||||
"err_code_expired_title": "انتهت صلاحية هذا الرمز",
|
||||
"err_code_invalid_detail": "اعرض رمزًا جديدًا في بريد الويب (الإعدادات ← الأمان ← ربط تطبيق الجوال) وامسحه.",
|
||||
"err_code_invalid_title": "هذا الرمز غير صالح",
|
||||
"err_code_used_detail": "كل رمز يعمل مرة واحدة فقط. اعرض رمزًا جديدًا في بريد الويب وامسحه مجددًا.",
|
||||
"err_code_used_title": "تم استخدام هذا الرمز من قبل",
|
||||
"err_generic_detail": "حدث خطأ ما. حاول مرة أخرى.",
|
||||
"err_generic_title": "فشل تسجيل الدخول",
|
||||
"err_interrupted_detail": "لم تتطابق الاستجابة مع الطلب الذي بدأناه. حاول تسجيل الدخول مرة أخرى.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "لا يوجد خادم بريد على {host}",
|
||||
"err_no_webmail_detail": "لا يحتوي هذا الخادم على بريد ويب Bulwark ولا على تسجيل دخول عبر OAuth. استخدم كلمة مرور (أو كلمة مرور تطبيق) بدلًا من ذلك.",
|
||||
"err_no_webmail_title": "لا توجد صفحة تسجيل دخول على {host}",
|
||||
"err_pair_connect_detail": "اعرض رمزًا جديدًا على حاسوبك وامسحه مجددًا.",
|
||||
"err_pair_connect_title": "تم تسجيل الدخول، لكن تعذّر الاتصال بـ {host}",
|
||||
"err_pair_insecure_detail": "يريد {host} استخدام اتصال غير مشفّر.",
|
||||
"err_pair_network_detail": "تحقق من اتصالك ومن أن هاتفك يستطيع الوصول إلى بريد الويب، ثم حاول مرة أخرى.",
|
||||
"err_pair_rate_detail": "انتظر دقيقة، ثم حاول مرة أخرى.",
|
||||
"err_pair_server_detail": "حاول مرة أخرى باستخدام رمز جديد. إذا استمر الفشل، فاتصل بالمسؤول.",
|
||||
"err_pair_server_title": "تعذّر على بريد الويب إكمال تسجيل الدخول",
|
||||
"err_pair_unsupported_detail": "لا يدعم {host} رموز تسجيل الدخول. قد يحتاج إلى تحديث، أو أن الرابط يشير إلى عنوان خاطئ.",
|
||||
"err_pair_unsupported_title": "لا يمكن لبريد الويب هذا ربط الأجهزة",
|
||||
"err_pair_untrusted_detail": "أرسل {host} بيانات تسجيل دخول لخادم لا يستطيع التطبيق التحقق منه.",
|
||||
"err_pair_untrusted_title": "لا يمكن الوثوق بعملية تسجيل الدخول هذه",
|
||||
"err_rate_detail": "طلب منا الخادم الانتظار قليلًا. حاول مجددًا بعد قليل.",
|
||||
"err_rate_detail_seconds": "طلب منا الخادم الانتظار. حاول مجددًا بعد نحو {seconds} ث.",
|
||||
"err_rate_title": "محاولات كثيرة جدًا",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "أعرف عنوان خادمي",
|
||||
"known_server_desc": "نعرف هذا الخادم بالفعل، لذا لا حاجة إلى إعداد",
|
||||
"known_server_title": "حساب آخر على {host}",
|
||||
"link_confirm_connect": "يطلب رابط تسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.",
|
||||
"link_confirm_connect_add": "يطلب رابط إضافة حساب بتسجيل الدخول في هذه الصفحة. لا تتابع إلا إذا كنت تستخدم {host} لبريدك.",
|
||||
"link_confirm_connect_title": "فتح صفحة تسجيل الدخول في {host}؟",
|
||||
"link_confirm_pair": "يسجّل هذا الرابط الدخول إلى حساب من {host}. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.",
|
||||
"link_confirm_pair_add": "يضيف هذا الرابط حسابًا من {host} وينقل التطبيق إليه. لا تتابع إلا إذا كنت قد عرضت للتو رمز تسجيل دخول على حاسوبك.",
|
||||
"link_confirm_title": "تسجيل الدخول برابط؟",
|
||||
"looking_up": "جارٍ البحث عن خادمك…",
|
||||
"manual": "إدخال تفاصيل الخادم يدويًا",
|
||||
"notice_bad_qr": "هذا الرمز ليس رمز تسجيل دخول Bulwark",
|
||||
@@ -505,6 +529,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": "يعرض قسم الإعدادات ← الأمان ← ربط تطبيق الجوال رمزًا. وجّه الكاميرا نحوه.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "العنوان نفسه الذي تكتبه في المتصفح. سنضيف https:// نيابةً عنك.",
|
||||
"server_label": "عنوان الخادم",
|
||||
"server_not_found": "لم نتمكن من العثور على خادم للنطاق {domain}. إذا كنت تستضيف خادمك بنفسك، فالعنوان هو الذي تستخدمه لبريد الويب.",
|
||||
"server_paste_hint": "هل لديك رابط تسجيل دخول بدلًا من ذلك؟ الصقه هنا.",
|
||||
"server_scan_hint": "إذا لم تكن متأكدًا، فامسح رمز تسجيل الدخول بدلًا من ذلك، فهو يتضمن عنوان الخادم.",
|
||||
"server_subtitle": "أدخل العنوان الذي تستخدمه للوصول إلى بريد الويب.",
|
||||
"server_title": "ما عنوان خادمك؟",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Quina és la vostra adreça electrònica?",
|
||||
"email_step_title_add": "Quina és l'altra adreça?",
|
||||
"email_title": "Utilitza la meva adreça electrònica",
|
||||
"err_account_limit_detail": "Suprimiu un compte a la configuració per afegir-ne un altre.",
|
||||
"err_bad_detail": "Comproveu el correu electrònic i la contrasenya. Si el vostre compte utilitza l'autenticació de dos factors, creeu una contrasenya d'aplicació al webmail i utilitzeu-la aquí.",
|
||||
"err_bad_title": "No ha funcionat",
|
||||
"err_cert_detail": "S'ha rebutjat el certificat de seguretat del servidor. Si és el vostre propi servidor, comproveu que el certificat sigui vàlid i no hagi caducat.",
|
||||
"err_cert_title": "No s'ha pogut verificar {host}",
|
||||
"err_code_expired_detail": "Els codis d'inici de sessió són vàlids durant uns minuts. Genereu-ne un de nou al webmail i torneu-lo a escanejar.",
|
||||
"err_code_expired_detail": "Els codis d'inici de sessió són vàlids durant dos minuts. Mostreu-ne un de nou al webmail i torneu-lo a escanejar.",
|
||||
"err_code_expired_or_used_detail": "Mostreu-ne un de nou al webmail i torneu-lo a escanejar.",
|
||||
"err_code_expired_or_used_title": "Aquest codi ha caducat o ja s'ha utilitzat",
|
||||
"err_code_expired_title": "Aquest codi ha caducat",
|
||||
"err_code_invalid_detail": "Mostreu un codi nou al webmail (Configuració → Seguretat → Enllaça l'aplicació mòbil) i escanegeu-lo.",
|
||||
"err_code_invalid_title": "Aquest codi no és vàlid",
|
||||
"err_code_used_detail": "Cada codi només funciona una vegada. Mostreu-ne un de nou al webmail i torneu-lo a escanejar.",
|
||||
"err_code_used_title": "Aquest codi ja s'ha utilitzat",
|
||||
"err_generic_detail": "Alguna cosa ha anat malament. Torneu-ho a provar.",
|
||||
"err_generic_title": "Ha fallat l'inici de sessió",
|
||||
"err_interrupted_detail": "La resposta no coincideix amb la sol·licitud que vam iniciar. Torneu a iniciar la sessió.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "No hi ha cap servidor de correu a {host}",
|
||||
"err_no_webmail_detail": "Aquest servidor no té webmail del Bulwark ni inici de sessió OAuth. Utilitzeu una contrasenya (o una contrasenya d'aplicació).",
|
||||
"err_no_webmail_title": "No hi ha cap pàgina d'inici de sessió a {host}",
|
||||
"err_pair_connect_detail": "Mostreu un codi nou a l'ordinador i torneu-lo a escanejar.",
|
||||
"err_pair_connect_title": "Sessió iniciada, però no s'ha pogut connectar a {host}",
|
||||
"err_pair_insecure_detail": "{host} vol utilitzar una connexió sense xifrar.",
|
||||
"err_pair_network_detail": "Comproveu la connexió i que el telèfon pugui accedir al webmail, i torneu-ho a provar.",
|
||||
"err_pair_rate_detail": "Espereu un minut i torneu-ho a provar.",
|
||||
"err_pair_server_detail": "Torneu-ho a provar amb un codi nou. Si continua fallant, consulteu l'administrador.",
|
||||
"err_pair_server_title": "El webmail no ha pogut completar l'inici de sessió",
|
||||
"err_pair_unsupported_detail": "{host} no admet codis d'inici de sessió. Potser necessita una actualització, o l'enllaç apunta a una adreça incorrecta.",
|
||||
"err_pair_unsupported_title": "Aquest webmail no pot enllaçar dispositius",
|
||||
"err_pair_untrusted_detail": "{host} ha enviat dades d'inici de sessió per a un servidor que l'aplicació no pot verificar.",
|
||||
"err_pair_untrusted_title": "No es pot confiar en aquest inici de sessió",
|
||||
"err_rate_detail": "El servidor ens ha demanat que esperem un moment. Torneu-ho a provar d'aquí a poc.",
|
||||
"err_rate_detail_seconds": "El servidor ens ha demanat que esperem. Torneu-ho a provar d'aquí a uns {seconds} segons.",
|
||||
"err_rate_title": "Massa intents",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Conec l'adreça del meu servidor",
|
||||
"known_server_desc": "Ja coneixem aquest servidor, així que no cal configurar res",
|
||||
"known_server_title": "Un altre compte a {host}",
|
||||
"link_confirm_connect": "Un enllaç demana iniciar la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.",
|
||||
"link_confirm_connect_add": "Un enllaç demana afegir un compte iniciant la sessió en aquesta pàgina. Continueu només si utilitzeu {host} per al correu.",
|
||||
"link_confirm_connect_title": "Voleu obrir la pàgina d'inici de sessió de {host}?",
|
||||
"link_confirm_pair": "Aquest enllaç inicia la sessió en un compte de {host}. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.",
|
||||
"link_confirm_pair_add": "Aquest enllaç afegeix un compte de {host} i hi canvia l'aplicació. Continueu només si acabeu de mostrar un codi d'inici de sessió a l'ordinador.",
|
||||
"link_confirm_title": "Voleu iniciar la sessió amb un enllaç?",
|
||||
"looking_up": "S'està cercant el vostre servidor…",
|
||||
"manual": "Introdueix les dades del servidor manualment",
|
||||
"notice_bad_qr": "Aquest codi no és un codi d'inici de sessió del Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Jaká je vaše e-mailová adresa?",
|
||||
"email_step_title_add": "Jaká je ta druhá adresa?",
|
||||
"email_title": "Použít moji e-mailovou adresu",
|
||||
"err_account_limit_detail": "Chcete-li přidat další účet, odeberte některý v Nastavení.",
|
||||
"err_bad_detail": "Zkontrolujte e-mail a heslo. Pokud váš účet používá dvoufázové přihlášení, vytvořte ve webmailu heslo aplikace a použijte ho zde.",
|
||||
"err_bad_title": "To nevyšlo",
|
||||
"err_cert_detail": "Bezpečnostní certifikát serveru byl odmítnut. Pokud jde o váš vlastní server, zkontrolujte, zda je certifikát platný a nevypršel.",
|
||||
"err_cert_title": "{host} nelze ověřit",
|
||||
"err_code_expired_detail": "Přihlašovací kódy platí několik minut. Vygenerujte ve webmailu nový a naskenujte ho znovu.",
|
||||
"err_code_expired_detail": "Přihlašovací kódy platí dvě minuty. Zobrazte ve webmailu nový a naskenujte ho znovu.",
|
||||
"err_code_expired_or_used_detail": "Zobrazte ve webmailu nový a naskenujte ho znovu.",
|
||||
"err_code_expired_or_used_title": "Platnost kódu vypršela nebo už byl použit",
|
||||
"err_code_expired_title": "Platnost kódu vypršela",
|
||||
"err_code_invalid_detail": "Zobrazte ve webmailu nový kód (Nastavení → Zabezpečení → Propojit mobilní aplikaci) a naskenujte ho.",
|
||||
"err_code_invalid_title": "Tento kód není platný",
|
||||
"err_code_used_detail": "Každý kód funguje jen jednou. Zobrazte ve webmailu nový a naskenujte ho znovu.",
|
||||
"err_code_used_title": "Tento kód už byl použit",
|
||||
"err_generic_detail": "Něco se pokazilo. Zkuste to znovu.",
|
||||
"err_generic_title": "Přihlášení selhalo",
|
||||
"err_interrupted_detail": "Odpověď neodpovídala zahájenému požadavku. Zkuste se přihlásit znovu.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Poštovní server nenalezen: {host}",
|
||||
"err_no_webmail_detail": "Tento server nemá webmail Bulwark ani přihlášení přes OAuth. Použijte heslo (nebo heslo aplikace).",
|
||||
"err_no_webmail_title": "Přihlašovací stránka nenalezena: {host}",
|
||||
"err_pair_connect_detail": "Zobrazte na počítači nový kód a naskenujte ho znovu.",
|
||||
"err_pair_connect_title": "Přihlášeno, ale nelze se připojit k {host}",
|
||||
"err_pair_insecure_detail": "{host} chce použít nešifrované spojení.",
|
||||
"err_pair_network_detail": "Zkontrolujte připojení a zda se telefon dostane k webmailu, a zkuste to znovu.",
|
||||
"err_pair_rate_detail": "Počkejte minutu a zkuste to znovu.",
|
||||
"err_pair_server_detail": "Zkuste to znovu s novým kódem. Pokud to stále selhává, obraťte se na správce.",
|
||||
"err_pair_server_title": "Webmail nemohl dokončit přihlášení",
|
||||
"err_pair_unsupported_detail": "{host} nepodporuje přihlašovací kódy. Možná potřebuje aktualizaci, nebo odkaz vede na špatnou adresu.",
|
||||
"err_pair_unsupported_title": "Tento webmail neumí propojovat zařízení",
|
||||
"err_pair_untrusted_detail": "{host} poslal přihlašovací údaje pro server, který aplikace nemůže ověřit.",
|
||||
"err_pair_untrusted_title": "Tomuto přihlášení nelze důvěřovat",
|
||||
"err_rate_detail": "Server nás požádal o chvíli strpení. Zkuste to brzy znovu.",
|
||||
"err_rate_detail_seconds": "Server nás požádal o strpení. Zkuste to znovu přibližně za {seconds} s.",
|
||||
"err_rate_title": "Příliš mnoho pokusů",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Znám adresu svého serveru",
|
||||
"known_server_desc": "Tento server už známe, takže není třeba nic nastavovat",
|
||||
"known_server_title": "Další účet na {host}",
|
||||
"link_confirm_connect": "Odkaz žádá o přihlášení na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.",
|
||||
"link_confirm_connect_add": "Odkaz žádá o přidání účtu přihlášením na této stránce. Pokračujte, jen pokud {host} používáte pro svůj e-mail.",
|
||||
"link_confirm_connect_title": "Otevřít přihlašovací stránku {host}?",
|
||||
"link_confirm_pair": "Tento odkaz vás přihlásí k účtu z {host}. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.",
|
||||
"link_confirm_pair_add": "Tento odkaz přidá účet z {host} a přepne na něj aplikaci. Pokračujte, jen pokud jste právě na počítači zobrazili přihlašovací kód.",
|
||||
"link_confirm_title": "Přihlásit se pomocí odkazu?",
|
||||
"looking_up": "Hledání vašeho serveru…",
|
||||
"manual": "Zadat údaje serveru ručně",
|
||||
"notice_bad_qr": "Toto není přihlašovací kód Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Hvad er din e-mailadresse?",
|
||||
"email_step_title_add": "Hvad er den anden adresse?",
|
||||
"email_title": "Brug min e-mailadresse",
|
||||
"err_account_limit_detail": "Fjern en konto i Indstillinger for at tilføje en ny.",
|
||||
"err_bad_detail": "Tjek din e-mail og adgangskode. Hvis din konto bruger to-faktor-godkendelse, så opret en app-adgangskode i webmailen, og brug den her.",
|
||||
"err_bad_title": "Det virkede ikke",
|
||||
"err_cert_detail": "Serverens sikkerhedscertifikat blev afvist. Hvis det er din egen server, så tjek, at certifikatet er gyldigt og ikke udløbet.",
|
||||
"err_cert_title": "Kunne ikke verificere {host}",
|
||||
"err_code_expired_detail": "Loginkoder er kun gyldige i nogle få minutter. Generér en ny i webmailen, og scan igen.",
|
||||
"err_code_expired_detail": "Loginkoder er gyldige i to minutter. Vis en ny i webmailen, og scan igen.",
|
||||
"err_code_expired_or_used_detail": "Vis en ny i webmailen, og scan igen.",
|
||||
"err_code_expired_or_used_title": "Den kode er udløbet eller allerede brugt",
|
||||
"err_code_expired_title": "Koden er udløbet",
|
||||
"err_code_invalid_detail": "Vis en ny kode i webmailen (Indstillinger → Sikkerhed → Tilknyt mobilapp), og scan den.",
|
||||
"err_code_invalid_title": "Den kode er ikke gyldig",
|
||||
"err_code_used_detail": "Hver kode virker kun én gang. Vis en ny i webmailen, og scan igen.",
|
||||
"err_code_used_title": "Den kode er allerede brugt",
|
||||
"err_generic_detail": "Noget gik galt. Prøv igen.",
|
||||
"err_generic_title": "Login mislykkedes",
|
||||
"err_interrupted_detail": "Svaret matchede ikke den anmodning, vi startede. Prøv at logge ind igen.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Ingen mailserver på {host}",
|
||||
"err_no_webmail_detail": "Denne server har hverken Bulwark-webmail eller OAuth-login. Brug i stedet en adgangskode (eller en app-adgangskode).",
|
||||
"err_no_webmail_title": "Ingen login-side på {host}",
|
||||
"err_pair_connect_detail": "Vis en ny kode på din computer, og scan igen.",
|
||||
"err_pair_connect_title": "Logget ind, men kunne ikke oprette forbindelse til {host}",
|
||||
"err_pair_insecure_detail": "{host} vil bruge en ukrypteret forbindelse.",
|
||||
"err_pair_network_detail": "Tjek din forbindelse, og at din telefon kan nå webmailen, og prøv så igen.",
|
||||
"err_pair_rate_detail": "Vent et minut, og prøv så igen.",
|
||||
"err_pair_server_detail": "Prøv igen med en ny kode. Hvis det bliver ved med at mislykkes, så kontakt din administrator.",
|
||||
"err_pair_server_title": "Webmailen kunne ikke gennemføre login",
|
||||
"err_pair_unsupported_detail": "{host} understøtter ikke loginkoder. Den skal måske opdateres, eller linket peger på den forkerte adresse.",
|
||||
"err_pair_unsupported_title": "Denne webmail kan ikke tilknytte enheder",
|
||||
"err_pair_untrusted_detail": "{host} sendte loginoplysninger til en server, som appen ikke kan bekræfte.",
|
||||
"err_pair_untrusted_title": "Dette login kan ikke betros",
|
||||
"err_rate_detail": "Serveren bad os vente et øjeblik. Prøv igen om lidt.",
|
||||
"err_rate_detail_seconds": "Serveren bad os vente. Prøv igen om cirka {seconds} sekunder.",
|
||||
"err_rate_title": "For mange forsøg",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Jeg kender min serveradresse",
|
||||
"known_server_desc": "Vi kender allerede denne server, så der skal ikke konfigureres noget",
|
||||
"known_server_title": "En anden konto på {host}",
|
||||
"link_confirm_connect": "Et link beder om at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.",
|
||||
"link_confirm_connect_add": "Et link beder om at tilføje en konto ved at logge ind på denne side. Fortsæt kun, hvis du bruger {host} til din mail.",
|
||||
"link_confirm_connect_title": "Åbn login-siden for {host}?",
|
||||
"link_confirm_pair": "Dette link logger ind på en konto fra {host}. Fortsæt kun, hvis du lige har vist en loginkode på din computer.",
|
||||
"link_confirm_pair_add": "Dette link tilføjer en konto fra {host} og skifter appen til den. Fortsæt kun, hvis du lige har vist en loginkode på din computer.",
|
||||
"link_confirm_title": "Log ind med et link?",
|
||||
"looking_up": "Slår din server op…",
|
||||
"manual": "Indtast serveroplysninger manuelt",
|
||||
"notice_bad_qr": "Den kode er ikke en Bulwark-loginkode",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Wie lautet Ihre E-Mail-Adresse?",
|
||||
"email_step_title_add": "Wie lautet die andere Adresse?",
|
||||
"email_title": "Meine E-Mail-Adresse verwenden",
|
||||
"err_account_limit_detail": "Entfernen Sie in den Einstellungen ein Konto, um ein weiteres hinzuzufügen.",
|
||||
"err_bad_detail": "Prüfen Sie E-Mail-Adresse und Passwort. Wenn Ihr Konto die Zwei-Faktor-Anmeldung nutzt, erstellen Sie im Webmail ein App-Passwort und verwenden Sie es hier.",
|
||||
"err_bad_title": "Das hat nicht funktioniert",
|
||||
"err_cert_detail": "Das Sicherheitszertifikat des Servers wurde abgelehnt. Wenn dies Ihr eigener Server ist, prüfen Sie, ob das Zertifikat gültig und nicht abgelaufen ist.",
|
||||
"err_cert_title": "Überprüfung von {host} fehlgeschlagen",
|
||||
"err_code_expired_detail": "Anmeldecodes sind nur wenige Minuten gültig. Erzeugen Sie im Webmail einen neuen und scannen Sie erneut.",
|
||||
"err_code_expired_detail": "Anmeldecodes sind zwei Minuten gültig. Zeigen Sie im Webmail einen neuen an und scannen Sie erneut.",
|
||||
"err_code_expired_or_used_detail": "Zeigen Sie im Webmail einen neuen an und scannen Sie erneut.",
|
||||
"err_code_expired_or_used_title": "Dieser Code ist abgelaufen oder wurde bereits verwendet",
|
||||
"err_code_expired_title": "Dieser Code ist abgelaufen",
|
||||
"err_code_invalid_detail": "Zeigen Sie im Webmail einen neuen Code an (Einstellungen → Sicherheit → Mobile App verknüpfen) und scannen Sie ihn.",
|
||||
"err_code_invalid_title": "Dieser Code ist ungültig",
|
||||
"err_code_used_detail": "Jeder Code funktioniert nur einmal. Zeigen Sie im Webmail einen neuen an und scannen Sie erneut.",
|
||||
"err_code_used_title": "Dieser Code wurde bereits verwendet",
|
||||
"err_generic_detail": "Etwas ist schiefgelaufen. Versuchen Sie es erneut.",
|
||||
"err_generic_title": "Anmeldung fehlgeschlagen",
|
||||
"err_interrupted_detail": "Die Antwort passte nicht zur gestarteten Anfrage. Melden Sie sich erneut an.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Kein Mailserver bei {host}",
|
||||
"err_no_webmail_detail": "Dieser Server hat weder ein Bulwark-Webmail noch eine OAuth-Anmeldung. Verwenden Sie stattdessen ein Passwort (oder ein App-Passwort).",
|
||||
"err_no_webmail_title": "Keine Anmeldeseite bei {host}",
|
||||
"err_pair_connect_detail": "Zeigen Sie auf Ihrem Computer einen neuen Code an und scannen Sie erneut.",
|
||||
"err_pair_connect_title": "Angemeldet, aber keine Verbindung zu {host}",
|
||||
"err_pair_insecure_detail": "{host} möchte eine unverschlüsselte Verbindung verwenden.",
|
||||
"err_pair_network_detail": "Prüfen Sie Ihre Verbindung und ob Ihr Telefon das Webmail erreichen kann, und versuchen Sie es dann erneut.",
|
||||
"err_pair_rate_detail": "Warten Sie eine Minute und versuchen Sie es dann erneut.",
|
||||
"err_pair_server_detail": "Versuchen Sie es mit einem neuen Code erneut. Wenn es weiterhin fehlschlägt, wenden Sie sich an Ihren Administrator.",
|
||||
"err_pair_server_title": "Das Webmail konnte die Anmeldung nicht abschließen",
|
||||
"err_pair_unsupported_detail": "{host} unterstützt keine Anmeldecodes. Eventuell ist ein Update nötig, oder der Link verweist auf die falsche Adresse.",
|
||||
"err_pair_unsupported_title": "Dieses Webmail kann keine Geräte verknüpfen",
|
||||
"err_pair_untrusted_detail": "{host} hat Anmeldedaten für einen Server gesendet, den die App nicht überprüfen kann.",
|
||||
"err_pair_untrusted_title": "Dieser Anmeldung kann nicht vertraut werden",
|
||||
"err_rate_detail": "Der Server hat uns gebeten, kurz zu warten. Versuchen Sie es gleich noch einmal.",
|
||||
"err_rate_detail_seconds": "Der Server hat uns gebeten zu warten. Versuchen Sie es in etwa {seconds} Sekunden erneut.",
|
||||
"err_rate_title": "Zu viele Versuche",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Ich kenne meine Serveradresse",
|
||||
"known_server_desc": "Diesen Server kennen wir bereits, daher ist keine Einrichtung nötig",
|
||||
"known_server_title": "Weiteres Konto auf {host}",
|
||||
"link_confirm_connect": "Ein Link möchte Sie auf dieser Seite anmelden. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.",
|
||||
"link_confirm_connect_add": "Ein Link möchte über die Anmeldung auf dieser Seite ein Konto hinzufügen. Fahren Sie nur fort, wenn Sie {host} für Ihre E-Mails verwenden.",
|
||||
"link_confirm_connect_title": "Anmeldeseite von {host} öffnen?",
|
||||
"link_confirm_pair": "Dieser Link meldet Sie bei einem Konto von {host} an. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.",
|
||||
"link_confirm_pair_add": "Dieser Link fügt ein Konto von {host} hinzu und wechselt die App zu diesem Konto. Fahren Sie nur fort, wenn Sie gerade auf Ihrem Computer einen Anmeldecode angezeigt haben.",
|
||||
"link_confirm_title": "Mit einem Link anmelden?",
|
||||
"looking_up": "Ihr Server wird gesucht…",
|
||||
"manual": "Serverdaten manuell eingeben",
|
||||
"notice_bad_qr": "Das ist kein Bulwark-Anmeldecode",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+35
-1
@@ -249,6 +249,9 @@
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag"
|
||||
},
|
||||
"deep_link": {
|
||||
"open_failed": "Couldn't open this"
|
||||
},
|
||||
"email_composer": {
|
||||
"attach_camera": "Camera",
|
||||
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
|
||||
@@ -467,12 +470,19 @@
|
||||
"email_step_title": "What's your email address?",
|
||||
"email_step_title_add": "What's the other address?",
|
||||
"email_title": "Use my email address",
|
||||
"err_account_limit_detail": "Remove an account in Settings to add another one.",
|
||||
"err_bad_detail": "Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.",
|
||||
"err_bad_title": "That didn't work",
|
||||
"err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
|
||||
"err_cert_title": "Couldn't verify {host}",
|
||||
"err_code_expired_detail": "Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.",
|
||||
"err_code_expired_detail": "Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.",
|
||||
"err_code_expired_or_used_detail": "Show a new one in the webmail and scan again.",
|
||||
"err_code_expired_or_used_title": "That code has expired or was already used",
|
||||
"err_code_expired_title": "That code has expired",
|
||||
"err_code_invalid_detail": "Show a new code in the webmail (Settings → Security → Link Mobile App) and scan it.",
|
||||
"err_code_invalid_title": "That code isn't valid",
|
||||
"err_code_used_detail": "Each code works once. Show a new one in the webmail and scan again.",
|
||||
"err_code_used_title": "That code was already used",
|
||||
"err_generic_detail": "Something went wrong. Try again.",
|
||||
"err_generic_title": "Sign-in failed",
|
||||
"err_interrupted_detail": "The response didn't match the request we started. Try signing in again.",
|
||||
@@ -481,6 +491,17 @@
|
||||
"err_no_server_title": "No mail server at {host}",
|
||||
"err_no_webmail_detail": "This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.",
|
||||
"err_no_webmail_title": "No sign-in page at {host}",
|
||||
"err_pair_connect_detail": "Show a new code on your computer and scan again.",
|
||||
"err_pair_connect_title": "Signed in, but couldn't connect to {host}",
|
||||
"err_pair_insecure_detail": "{host} wants to use an unencrypted connection.",
|
||||
"err_pair_network_detail": "Check your connection and that your phone can reach the webmail, then try again.",
|
||||
"err_pair_rate_detail": "Wait a minute, then try again.",
|
||||
"err_pair_server_detail": "Try again with a new code. If it keeps failing, ask your administrator.",
|
||||
"err_pair_server_title": "The webmail couldn't complete the sign-in",
|
||||
"err_pair_unsupported_detail": "{host} doesn't support sign-in codes. It may need an update, or the link points to the wrong address.",
|
||||
"err_pair_unsupported_title": "This webmail can't link devices",
|
||||
"err_pair_untrusted_detail": "{host} sent sign-in details for a server the app can't verify.",
|
||||
"err_pair_untrusted_title": "This sign-in can't be trusted",
|
||||
"err_rate_detail": "The server asked us to wait a moment. Try again shortly.",
|
||||
"err_rate_detail_seconds": "The server asked us to wait. Try again in about {seconds} seconds.",
|
||||
"err_rate_title": "Too many attempts",
|
||||
@@ -494,6 +515,12 @@
|
||||
"know_server": "I know my server address",
|
||||
"known_server_desc": "We already know this server, so there's no setup",
|
||||
"known_server_title": "Another account on {host}",
|
||||
"link_confirm_connect": "A link asks to sign in on this page. Only continue if you use {host} for your mail.",
|
||||
"link_confirm_connect_add": "A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.",
|
||||
"link_confirm_connect_title": "Open the sign-in page of {host}?",
|
||||
"link_confirm_pair": "This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.",
|
||||
"link_confirm_pair_add": "This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.",
|
||||
"link_confirm_title": "Sign in with a link?",
|
||||
"looking_up": "Looking up your server…",
|
||||
"manual": "Enter server details manually",
|
||||
"notice_bad_qr": "That code isn't a Bulwark sign-in code",
|
||||
@@ -512,6 +539,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.",
|
||||
@@ -522,6 +555,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "¿Cuál es tu dirección de correo?",
|
||||
"email_step_title_add": "¿Cuál es la otra dirección?",
|
||||
"email_title": "Usar mi dirección de correo",
|
||||
"err_account_limit_detail": "Elimina una cuenta en Ajustes para añadir otra.",
|
||||
"err_bad_detail": "Comprueba tu correo y tu contraseña. Si tu cuenta usa autenticación de dos factores, crea una contraseña de aplicación en el webmail y úsala aquí.",
|
||||
"err_bad_title": "No ha funcionado",
|
||||
"err_cert_detail": "Se rechazó el certificado de seguridad del servidor. Si es tu propio servidor, comprueba que el certificado sea válido y no haya caducado.",
|
||||
"err_cert_title": "No se pudo verificar {host}",
|
||||
"err_code_expired_detail": "Los códigos de inicio de sesión son válidos durante unos minutos. Genera uno nuevo en el webmail y vuelve a escanearlo.",
|
||||
"err_code_expired_detail": "Los códigos de inicio de sesión son válidos durante dos minutos. Muestra uno nuevo en el webmail y vuelve a escanearlo.",
|
||||
"err_code_expired_or_used_detail": "Muestra uno nuevo en el webmail y vuelve a escanearlo.",
|
||||
"err_code_expired_or_used_title": "Ese código ha caducado o ya se ha usado",
|
||||
"err_code_expired_title": "Ese código ha caducado",
|
||||
"err_code_invalid_detail": "Muestra un código nuevo en el webmail (Configuración → Seguridad → Vincular aplicación móvil) y escanéalo.",
|
||||
"err_code_invalid_title": "Ese código no es válido",
|
||||
"err_code_used_detail": "Cada código funciona una sola vez. Muestra uno nuevo en el webmail y vuelve a escanearlo.",
|
||||
"err_code_used_title": "Ese código ya se ha usado",
|
||||
"err_generic_detail": "Algo salió mal. Inténtalo de nuevo.",
|
||||
"err_generic_title": "Error al iniciar sesión",
|
||||
"err_interrupted_detail": "La respuesta no coincide con la solicitud que iniciamos. Vuelve a iniciar sesión.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "No hay ningún servidor de correo en {host}",
|
||||
"err_no_webmail_detail": "Este servidor no tiene webmail de Bulwark ni inicio de sesión OAuth. Usa una contraseña (o una contraseña de aplicación).",
|
||||
"err_no_webmail_title": "No hay página de inicio de sesión en {host}",
|
||||
"err_pair_connect_detail": "Muestra un código nuevo en tu ordenador y vuelve a escanearlo.",
|
||||
"err_pair_connect_title": "Sesión iniciada, pero no se pudo conectar con {host}",
|
||||
"err_pair_insecure_detail": "{host} quiere usar una conexión sin cifrar.",
|
||||
"err_pair_network_detail": "Comprueba tu conexión y que tu teléfono pueda acceder al webmail, y vuelve a intentarlo.",
|
||||
"err_pair_rate_detail": "Espera un minuto y vuelve a intentarlo.",
|
||||
"err_pair_server_detail": "Vuelve a intentarlo con un código nuevo. Si sigue fallando, consulta a tu administrador.",
|
||||
"err_pair_server_title": "El webmail no ha podido completar el inicio de sesión",
|
||||
"err_pair_unsupported_detail": "{host} no admite códigos de inicio de sesión. Puede que necesite una actualización o que el enlace apunte a una dirección incorrecta.",
|
||||
"err_pair_unsupported_title": "Este webmail no puede vincular dispositivos",
|
||||
"err_pair_untrusted_detail": "{host} ha enviado datos de inicio de sesión para un servidor que la aplicación no puede verificar.",
|
||||
"err_pair_untrusted_title": "No se puede confiar en este inicio de sesión",
|
||||
"err_rate_detail": "El servidor nos pidió esperar un momento. Inténtalo de nuevo en breve.",
|
||||
"err_rate_detail_seconds": "El servidor nos pidió esperar. Inténtalo de nuevo en unos {seconds} segundos.",
|
||||
"err_rate_title": "Demasiados intentos",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Conozco la dirección de mi servidor",
|
||||
"known_server_desc": "Ya conocemos este servidor, así que no hay nada que configurar",
|
||||
"known_server_title": "Otra cuenta en {host}",
|
||||
"link_confirm_connect": "Un enlace pide iniciar sesión en esta página. Continúa solo si usas {host} para tu correo.",
|
||||
"link_confirm_connect_add": "Un enlace pide añadir una cuenta iniciando sesión en esta página. Continúa solo si usas {host} para tu correo.",
|
||||
"link_confirm_connect_title": "¿Abrir la página de inicio de sesión de {host}?",
|
||||
"link_confirm_pair": "Este enlace inicia sesión en una cuenta de {host}. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.",
|
||||
"link_confirm_pair_add": "Este enlace añade una cuenta de {host} y cambia la aplicación a ella. Continúa solo si acabas de mostrar un código de inicio de sesión en tu ordenador.",
|
||||
"link_confirm_title": "¿Iniciar sesión con un enlace?",
|
||||
"looking_up": "Buscando tu servidor…",
|
||||
"manual": "Introducir los datos del servidor manualmente",
|
||||
"notice_bad_qr": "Ese código no es un código de inicio de sesión de Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "نشانی ایمیل شما چیست؟",
|
||||
"email_step_title_add": "نشانی دیگر چیست؟",
|
||||
"email_title": "استفاده از نشانی ایمیل من",
|
||||
"err_account_limit_detail": "برای افزودن حساب دیگر، یکی از حسابها را در تنظیمات حذف کنید.",
|
||||
"err_bad_detail": "ایمیل و رمز عبور خود را بررسی کنید. اگر حساب شما از ورود دو مرحلهای استفاده میکند، در ایمیل تحت وب یک رمز برنامه بسازید و آن را اینجا وارد کنید.",
|
||||
"err_bad_title": "موفق نشد",
|
||||
"err_cert_detail": "گواهی امنیتی سرور رد شد. اگر این سرور خودتان است، بررسی کنید گواهی معتبر باشد و منقضی نشده باشد.",
|
||||
"err_cert_title": "تأیید {host} ممکن نشد",
|
||||
"err_code_expired_detail": "کدهای ورود فقط چند دقیقه اعتبار دارند. در ایمیل تحت وب کد تازهای بسازید و دوباره اسکن کنید.",
|
||||
"err_code_expired_detail": "کدهای ورود دو دقیقه اعتبار دارند. در ایمیل تحت وب کد تازهای نمایش دهید و دوباره اسکن کنید.",
|
||||
"err_code_expired_or_used_detail": "در ایمیل تحت وب کد تازهای نمایش دهید و دوباره اسکن کنید.",
|
||||
"err_code_expired_or_used_title": "این کد منقضی شده یا قبلاً استفاده شده است",
|
||||
"err_code_expired_title": "این کد منقضی شده است",
|
||||
"err_code_invalid_detail": "در ایمیل تحت وب کد تازهای نمایش دهید (تنظیمات ← امنیت ← اتصال موبایل) و آن را اسکن کنید.",
|
||||
"err_code_invalid_title": "این کد معتبر نیست",
|
||||
"err_code_used_detail": "هر کد فقط یک بار کار میکند. در ایمیل تحت وب کد تازهای نمایش دهید و دوباره اسکن کنید.",
|
||||
"err_code_used_title": "این کد قبلاً استفاده شده است",
|
||||
"err_generic_detail": "مشکلی پیش آمد. دوباره امتحان کنید.",
|
||||
"err_generic_title": "ورود ناموفق بود",
|
||||
"err_interrupted_detail": "پاسخ با درخواستی که آغاز کردیم مطابقت نداشت. دوباره وارد شوید.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "سرور ایمیلی در {host} نیست",
|
||||
"err_no_webmail_detail": "این سرور نه ایمیل تحت وب Bulwark دارد و نه ورود با OAuth. بهجای آن از رمز عبور (یا رمز برنامه) استفاده کنید.",
|
||||
"err_no_webmail_title": "صفحه ورودی در {host} نیست",
|
||||
"err_pair_connect_detail": "روی رایانهتان کد تازهای نمایش دهید و دوباره اسکن کنید.",
|
||||
"err_pair_connect_title": "وارد شدید، اما اتصال به {host} ممکن نشد",
|
||||
"err_pair_insecure_detail": "{host} میخواهد از اتصال رمزگذارینشده استفاده کند.",
|
||||
"err_pair_network_detail": "اتصال خود را بررسی کنید و مطمئن شوید تلفنتان به ایمیل تحت وب دسترسی دارد، سپس دوباره امتحان کنید.",
|
||||
"err_pair_rate_detail": "یک دقیقه صبر کنید، سپس دوباره امتحان کنید.",
|
||||
"err_pair_server_detail": "با یک کد تازه دوباره امتحان کنید. اگر باز هم ناموفق بود، با مدیر سیستم تماس بگیرید.",
|
||||
"err_pair_server_title": "ایمیل تحت وب نتوانست ورود را کامل کند",
|
||||
"err_pair_unsupported_detail": "{host} از کدهای ورود پشتیبانی نمیکند. شاید نیاز به بهروزرسانی داشته باشد، یا پیوند به نشانی اشتباهی اشاره میکند.",
|
||||
"err_pair_unsupported_title": "این ایمیل تحت وب نمیتواند دستگاهها را متصل کند",
|
||||
"err_pair_untrusted_detail": "{host} اطلاعات ورود را برای سروری فرستاده که برنامه نمیتواند آن را تأیید کند.",
|
||||
"err_pair_untrusted_title": "به این ورود نمیتوان اعتماد کرد",
|
||||
"err_rate_detail": "سرور از ما خواست کمی صبر کنیم. کمی بعد دوباره امتحان کنید.",
|
||||
"err_rate_detail_seconds": "سرور از ما خواست صبر کنیم. حدود {seconds} ثانیه دیگر دوباره امتحان کنید.",
|
||||
"err_rate_title": "تلاشهای بیش از حد",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "نشانی سرورم را میدانم",
|
||||
"known_server_desc": "این سرور را از قبل میشناسیم، پس نیازی به راهاندازی نیست",
|
||||
"known_server_title": "حسابی دیگر در {host}",
|
||||
"link_confirm_connect": "یک پیوند میخواهد در این صفحه وارد شوید. فقط اگر برای ایمیلتان از {host} استفاده میکنید ادامه دهید.",
|
||||
"link_confirm_connect_add": "یک پیوند میخواهد با ورود در این صفحه حسابی اضافه کنید. فقط اگر برای ایمیلتان از {host} استفاده میکنید ادامه دهید.",
|
||||
"link_confirm_connect_title": "صفحه ورود {host} باز شود؟",
|
||||
"link_confirm_pair": "این پیوند شما را به حسابی از {host} وارد میکند. فقط اگر همین حالا روی رایانهتان کد ورود نمایش دادهاید ادامه دهید.",
|
||||
"link_confirm_pair_add": "این پیوند حسابی از {host} را اضافه میکند و برنامه را به آن حساب میبرد. فقط اگر همین حالا روی رایانهتان کد ورود نمایش دادهاید ادامه دهید.",
|
||||
"link_confirm_title": "با پیوند وارد میشوید؟",
|
||||
"looking_up": "در حال جستجوی سرور شما…",
|
||||
"manual": "وارد کردن دستی مشخصات سرور",
|
||||
"notice_bad_qr": "این کد، کد ورود Bulwark نیست",
|
||||
@@ -505,6 +529,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": "در تنظیمات ← امنیت ← اتصال موبایل یک کد نمایش داده میشود. دوربین را به سمت آن بگیرید.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "همان نشانیای که در مرورگر تایپ میکنید. https:// را خودمان اضافه میکنیم.",
|
||||
"server_label": "نشانی سرور",
|
||||
"server_not_found": "سروری برای {domain} پیدا نکردیم. اگر سرور خودتان را میزبانی میکنید، نشانی همان است که برای ایمیل تحت وب استفاده میکنید.",
|
||||
"server_paste_hint": "بهجای آن پیوند ورود دارید؟ اینجا بچسبانید.",
|
||||
"server_scan_hint": "اگر مطمئن نیستید، بهجای آن کد ورود را اسکن کنید. نشانی سرور در آن هست.",
|
||||
"server_subtitle": "نشانیای را که با آن به ایمیل تحت وب دسترسی دارید وارد کنید.",
|
||||
"server_title": "نشانی سرور شما چیست؟",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Quelle est votre adresse e-mail ?",
|
||||
"email_step_title_add": "Quelle est l'autre adresse ?",
|
||||
"email_title": "Utiliser mon adresse e-mail",
|
||||
"err_account_limit_detail": "Supprimez un compte dans les Paramètres pour en ajouter un autre.",
|
||||
"err_bad_detail": "Vérifiez votre adresse e-mail et votre mot de passe. Si votre compte utilise l'authentification à deux facteurs, créez un mot de passe d'application dans le webmail et utilisez-le ici.",
|
||||
"err_bad_title": "Cela n'a pas fonctionné",
|
||||
"err_cert_detail": "Le certificat de sécurité du serveur a été refusé. S'il s'agit de votre propre serveur, vérifiez que le certificat est valide et n'a pas expiré.",
|
||||
"err_cert_title": "Impossible de vérifier {host}",
|
||||
"err_code_expired_detail": "Les codes de connexion ne sont valables que quelques minutes. Générez-en un nouveau dans le webmail et scannez-le à nouveau.",
|
||||
"err_code_expired_detail": "Les codes de connexion sont valables deux minutes. Affichez-en un nouveau dans le webmail et scannez-le à nouveau.",
|
||||
"err_code_expired_or_used_detail": "Affichez-en un nouveau dans le webmail et scannez-le à nouveau.",
|
||||
"err_code_expired_or_used_title": "Ce code a expiré ou a déjà été utilisé",
|
||||
"err_code_expired_title": "Ce code a expiré",
|
||||
"err_code_invalid_detail": "Affichez un nouveau code dans le webmail (Paramètres → Sécurité → Associer l'application mobile) et scannez-le.",
|
||||
"err_code_invalid_title": "Ce code n'est pas valide",
|
||||
"err_code_used_detail": "Chaque code ne fonctionne qu'une fois. Affichez-en un nouveau dans le webmail et scannez-le à nouveau.",
|
||||
"err_code_used_title": "Ce code a déjà été utilisé",
|
||||
"err_generic_detail": "Un problème est survenu. Réessayez.",
|
||||
"err_generic_title": "Échec de la connexion",
|
||||
"err_interrupted_detail": "La réponse ne correspondait pas à la requête que nous avions lancée. Essayez de vous reconnecter.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Aucun serveur de messagerie sur {host}",
|
||||
"err_no_webmail_detail": "Ce serveur n'a ni webmail Bulwark ni connexion OAuth. Utilisez plutôt un mot de passe (ou un mot de passe d'application).",
|
||||
"err_no_webmail_title": "Aucune page de connexion sur {host}",
|
||||
"err_pair_connect_detail": "Affichez un nouveau code sur votre ordinateur et scannez-le à nouveau.",
|
||||
"err_pair_connect_title": "Connecté, mais impossible de joindre {host}",
|
||||
"err_pair_insecure_detail": "{host} veut utiliser une connexion non chiffrée.",
|
||||
"err_pair_network_detail": "Vérifiez votre connexion et que votre téléphone peut accéder au webmail, puis réessayez.",
|
||||
"err_pair_rate_detail": "Patientez une minute, puis réessayez.",
|
||||
"err_pair_server_detail": "Réessayez avec un nouveau code. Si le problème persiste, contactez votre administrateur.",
|
||||
"err_pair_server_title": "Le webmail n'a pas pu terminer la connexion",
|
||||
"err_pair_unsupported_detail": "{host} ne prend pas en charge les codes de connexion. Il a peut-être besoin d'une mise à jour, ou le lien pointe vers la mauvaise adresse.",
|
||||
"err_pair_unsupported_title": "Ce webmail ne peut pas associer d'appareils",
|
||||
"err_pair_untrusted_detail": "{host} a envoyé des informations de connexion pour un serveur que l'application ne peut pas vérifier.",
|
||||
"err_pair_untrusted_title": "Cette connexion n'est pas fiable",
|
||||
"err_rate_detail": "Le serveur nous a demandé de patienter un instant. Réessayez dans un moment.",
|
||||
"err_rate_detail_seconds": "Le serveur nous a demandé de patienter. Réessayez dans environ {seconds} secondes.",
|
||||
"err_rate_title": "Trop de tentatives",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Je connais l'adresse de mon serveur",
|
||||
"known_server_desc": "Nous connaissons déjà ce serveur, aucune configuration n'est donc nécessaire",
|
||||
"known_server_title": "Un autre compte sur {host}",
|
||||
"link_confirm_connect": "Un lien demande à vous connecter sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.",
|
||||
"link_confirm_connect_add": "Un lien demande à ajouter un compte en vous connectant sur cette page. Ne continuez que si vous utilisez {host} pour vos e-mails.",
|
||||
"link_confirm_connect_title": "Ouvrir la page de connexion de {host} ?",
|
||||
"link_confirm_pair": "Ce lien vous connecte à un compte de {host}. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.",
|
||||
"link_confirm_pair_add": "Ce lien ajoute un compte de {host} et fait basculer l'application sur ce compte. Ne continuez que si vous venez d'afficher un code de connexion sur votre ordinateur.",
|
||||
"link_confirm_title": "Se connecter avec un lien ?",
|
||||
"looking_up": "Recherche de votre serveur…",
|
||||
"manual": "Saisir les informations du serveur manuellement",
|
||||
"notice_bad_qr": "Ce code n'est pas un code de connexion Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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 ?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "מה כתובת האימייל שלך?",
|
||||
"email_step_title_add": "מה הכתובת האחרת?",
|
||||
"email_title": "השתמש בכתובת האימייל שלי",
|
||||
"err_account_limit_detail": "הסר חשבון בהגדרות כדי להוסיף חשבון נוסף.",
|
||||
"err_bad_detail": "בדוק את האימייל והסיסמה. אם החשבון שלך משתמש באימות דו-שלבי, צור סיסמת אפליקציה בדואר האינטרנט והשתמש בה כאן.",
|
||||
"err_bad_title": "זה לא עבד",
|
||||
"err_cert_detail": "תעודת האבטחה של השרת נדחתה. אם זה השרת שלך, ודא שהתעודה תקפה ושתוקפה לא פג.",
|
||||
"err_cert_title": "לא ניתן לאמת את {host}",
|
||||
"err_code_expired_detail": "קודי התחברות תקפים לכמה דקות בלבד. צור קוד חדש בדואר האינטרנט וסרוק שוב.",
|
||||
"err_code_expired_detail": "קודי התחברות תקפים לשתי דקות. הצג קוד חדש בדואר האינטרנט וסרוק שוב.",
|
||||
"err_code_expired_or_used_detail": "הצג קוד חדש בדואר האינטרנט וסרוק שוב.",
|
||||
"err_code_expired_or_used_title": "תוקף הקוד פג או שהוא כבר נוצל",
|
||||
"err_code_expired_title": "תוקף הקוד פג",
|
||||
"err_code_invalid_detail": "הצג קוד חדש בדואר האינטרנט (הגדרות ← אבטחה ← הקשר יישום נייד) וסרוק אותו.",
|
||||
"err_code_invalid_title": "הקוד הזה אינו תקף",
|
||||
"err_code_used_detail": "כל קוד עובד פעם אחת בלבד. הצג קוד חדש בדואר האינטרנט וסרוק שוב.",
|
||||
"err_code_used_title": "הקוד הזה כבר נוצל",
|
||||
"err_generic_detail": "משהו השתבש. נסה שוב.",
|
||||
"err_generic_title": "ההתחברות נכשלה",
|
||||
"err_interrupted_detail": "התגובה לא תאמה לבקשה שהתחלנו. נסה להתחבר שוב.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "לא נמצא שרת דואר: {host}",
|
||||
"err_no_webmail_detail": "בשרת זה אין דואר אינטרנט של Bulwark ואין התחברות OAuth. השתמש במקום זאת בסיסמה (או בסיסמת אפליקציה).",
|
||||
"err_no_webmail_title": "אין דף התחברות: {host}",
|
||||
"err_pair_connect_detail": "הצג קוד חדש במחשב וסרוק שוב.",
|
||||
"err_pair_connect_title": "ההתחברות הצליחה, אך אין חיבור אל {host}",
|
||||
"err_pair_insecure_detail": "{host} רוצה להשתמש בחיבור לא מוצפן.",
|
||||
"err_pair_network_detail": "בדוק את החיבור ושהטלפון יכול להגיע לדואר האינטרנט, ונסה שוב.",
|
||||
"err_pair_rate_detail": "המתן דקה ונסה שוב.",
|
||||
"err_pair_server_detail": "נסה שוב עם קוד חדש. אם זה ממשיך להיכשל, פנה למנהל המערכת.",
|
||||
"err_pair_server_title": "דואר האינטרנט לא הצליח להשלים את ההתחברות",
|
||||
"err_pair_unsupported_detail": "{host} לא תומך בקודי התחברות. ייתכן שנדרש עדכון, או שהקישור מפנה לכתובת שגויה.",
|
||||
"err_pair_unsupported_title": "דואר האינטרנט הזה לא יכול לקשר מכשירים",
|
||||
"err_pair_untrusted_detail": "{host} שלח פרטי התחברות לשרת שהיישום לא יכול לאמת.",
|
||||
"err_pair_untrusted_title": "אי אפשר לסמוך על ההתחברות הזו",
|
||||
"err_rate_detail": "השרת ביקש שנמתין רגע. נסה שוב בקרוב.",
|
||||
"err_rate_detail_seconds": "השרת ביקש שנמתין. נסה שוב בעוד כ-{seconds} שניות.",
|
||||
"err_rate_title": "יותר מדי ניסיונות",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "אני יודע את כתובת השרת שלי",
|
||||
"known_server_desc": "השרת הזה כבר מוכר לנו, כך שאין צורך בהגדרה",
|
||||
"known_server_title": "חשבון נוסף ב-{host}",
|
||||
"link_confirm_connect": "קישור מבקש להתחבר בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.",
|
||||
"link_confirm_connect_add": "קישור מבקש להוסיף חשבון באמצעות התחברות בדף הזה. המשך רק אם אתה משתמש ב-{host} לדואר שלך.",
|
||||
"link_confirm_connect_title": "לפתוח את דף ההתחברות של {host}?",
|
||||
"link_confirm_pair": "הקישור הזה מחבר לחשבון מ-{host}. המשך רק אם הצגת עכשיו קוד התחברות במחשב.",
|
||||
"link_confirm_pair_add": "הקישור הזה מוסיף חשבון מ-{host} ומעביר אליו את היישום. המשך רק אם הצגת עכשיו קוד התחברות במחשב.",
|
||||
"link_confirm_title": "להתחבר עם קישור?",
|
||||
"looking_up": "מחפש את השרת שלך…",
|
||||
"manual": "הזן את פרטי השרת ידנית",
|
||||
"notice_bad_qr": "הקוד הזה אינו קוד התחברות של Bulwark",
|
||||
@@ -505,6 +529,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": "בהגדרות ← אבטחה ← הקשר יישום נייד מוצג קוד. כוון אליו את המצלמה.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "אותה כתובת שאתה מקליד בדפדפן. נוסיף בשבילך את https://.",
|
||||
"server_label": "כתובת השרת",
|
||||
"server_not_found": "לא מצאנו שרת עבור {domain}. אם אתה מארח בעצמך, הכתובת היא זו שבה אתה משתמש לדואר האינטרנט.",
|
||||
"server_paste_hint": "יש לך קישור התחברות במקום זאת? הדבק אותו כאן.",
|
||||
"server_scan_hint": "אם אינך בטוח, סרוק במקום זאת קוד התחברות. הוא כולל את כתובת השרת.",
|
||||
"server_subtitle": "הזן את הכתובת שבה אתה ניגש לדואר האינטרנט.",
|
||||
"server_title": "מה כתובת השרת שלך?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Mi az e-mail-címed?",
|
||||
"email_step_title_add": "Mi a másik cím?",
|
||||
"email_title": "E-mail-címem használata",
|
||||
"err_account_limit_detail": "Másik fiók hozzáadásához távolíts el egyet a Beállításokban.",
|
||||
"err_bad_detail": "Ellenőrizd az e-mail-címet és a jelszót. Ha a fiókod kétlépcsős bejelentkezést használ, hozz létre alkalmazásjelszót a webmailben, és azt használd itt.",
|
||||
"err_bad_title": "Ez nem sikerült",
|
||||
"err_cert_detail": "A szerver biztonsági tanúsítványát elutasítottuk. Ha ez a saját szervered, ellenőrizd, hogy a tanúsítvány érvényes és nem járt le.",
|
||||
"err_cert_title": "Nem sikerült ellenőrizni: {host}",
|
||||
"err_code_expired_detail": "A bejelentkezési kódok néhány percig érvényesek. Generálj újat a webmailben, és olvasd be újra.",
|
||||
"err_code_expired_detail": "A bejelentkezési kódok két percig érvényesek. Jeleníts meg újat a webmailben, és olvasd be újra.",
|
||||
"err_code_expired_or_used_detail": "Jeleníts meg újat a webmailben, és olvasd be újra.",
|
||||
"err_code_expired_or_used_title": "Ez a kód lejárt, vagy már felhasználták",
|
||||
"err_code_expired_title": "Ez a kód lejárt",
|
||||
"err_code_invalid_detail": "Jeleníts meg új kódot a webmailben (Beállítások → Biztonság → Mobilalkalmazás összekapcsolása), és olvasd be.",
|
||||
"err_code_invalid_title": "Ez a kód érvénytelen",
|
||||
"err_code_used_detail": "Minden kód csak egyszer használható. Jeleníts meg újat a webmailben, és olvasd be újra.",
|
||||
"err_code_used_title": "Ezt a kódot már felhasználták",
|
||||
"err_generic_detail": "Valami hiba történt. Próbáld újra.",
|
||||
"err_generic_title": "Sikertelen bejelentkezés",
|
||||
"err_interrupted_detail": "A válasz nem egyezett az elindított kéréssel. Próbálj meg újra bejelentkezni.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Nincs levelezőszerver itt: {host}",
|
||||
"err_no_webmail_detail": "Ezen a szerveren nincs Bulwark webmail és OAuth-bejelentkezés sem. Használj inkább jelszót (vagy alkalmazásjelszót).",
|
||||
"err_no_webmail_title": "Nincs bejelentkezési oldal itt: {host}",
|
||||
"err_pair_connect_detail": "Jeleníts meg új kódot a számítógépen, és olvasd be újra.",
|
||||
"err_pair_connect_title": "Bejelentkezve, de nem sikerült csatlakozni: {host}",
|
||||
"err_pair_insecure_detail": "A(z) {host} titkosítatlan kapcsolatot akar használni.",
|
||||
"err_pair_network_detail": "Ellenőrizd a kapcsolatot, és hogy a telefonod eléri-e a webmailt, majd próbáld újra.",
|
||||
"err_pair_rate_detail": "Várj egy percet, majd próbáld újra.",
|
||||
"err_pair_server_detail": "Próbáld újra egy új kóddal. Ha továbbra sem sikerül, fordulj a rendszergazdához.",
|
||||
"err_pair_server_title": "A webmail nem tudta befejezni a bejelentkezést",
|
||||
"err_pair_unsupported_detail": "A(z) {host} nem támogatja a bejelentkezési kódokat. Lehet, hogy frissíteni kell, vagy a hivatkozás rossz címre mutat.",
|
||||
"err_pair_unsupported_title": "Ez a webmail nem tud eszközöket összekapcsolni",
|
||||
"err_pair_untrusted_detail": "A(z) {host} bejelentkezési adatokat küldött egy olyan szerverhez, amelyet az alkalmazás nem tud ellenőrizni.",
|
||||
"err_pair_untrusted_title": "Ebben a bejelentkezésben nem lehet megbízni",
|
||||
"err_rate_detail": "A szerver arra kért, hogy várjunk egy kicsit. Próbáld újra hamarosan.",
|
||||
"err_rate_detail_seconds": "A szerver arra kért, hogy várjunk. Próbáld újra kb. {seconds} másodperc múlva.",
|
||||
"err_rate_title": "Túl sok próbálkozás",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Ismerem a szerverem címét",
|
||||
"known_server_desc": "Ezt a szervert már ismerjük, így nincs mit beállítani",
|
||||
"known_server_title": "Másik fiók itt: {host}",
|
||||
"link_confirm_connect": "Egy hivatkozás bejelentkezést kér ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.",
|
||||
"link_confirm_connect_add": "Egy hivatkozás egy fiók hozzáadását kéri bejelentkezéssel ezen az oldalon. Csak akkor folytasd, ha a(z) {host} címet használod a leveleidhez.",
|
||||
"link_confirm_connect_title": "Megnyitod a(z) {host} bejelentkezési oldalát?",
|
||||
"link_confirm_pair": "Ez a hivatkozás bejelentkeztet egy fiókba itt: {host}. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.",
|
||||
"link_confirm_pair_add": "Ez a hivatkozás hozzáad egy fiókot innen: {host}, és átváltja rá az alkalmazást. Csak akkor folytasd, ha az imént jelenítettél meg bejelentkezési kódot a számítógépeden.",
|
||||
"link_confirm_title": "Bejelentkezel egy hivatkozással?",
|
||||
"looking_up": "Szerver keresése…",
|
||||
"manual": "Szerveradatok kézi megadása",
|
||||
"notice_bad_qr": "Ez nem Bulwark bejelentkezési kód",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Qual è il tuo indirizzo email?",
|
||||
"email_step_title_add": "Qual è l'altro indirizzo?",
|
||||
"email_title": "Usa il mio indirizzo email",
|
||||
"err_account_limit_detail": "Rimuovi un account in Impostazioni per aggiungerne un altro.",
|
||||
"err_bad_detail": "Controlla email e password. Se il tuo account usa l'autenticazione a due fattori, crea una password per le app nella webmail e usala qui.",
|
||||
"err_bad_title": "Non ha funzionato",
|
||||
"err_cert_detail": "Il certificato di sicurezza del server è stato rifiutato. Se è il tuo server, verifica che il certificato sia valido e non scaduto.",
|
||||
"err_cert_title": "Impossibile verificare {host}",
|
||||
"err_code_expired_detail": "I codici di accesso sono validi per pochi minuti. Generane uno nuovo nella webmail e scansionalo di nuovo.",
|
||||
"err_code_expired_detail": "I codici di accesso sono validi per due minuti. Mostrane uno nuovo nella webmail e scansionalo di nuovo.",
|
||||
"err_code_expired_or_used_detail": "Mostrane uno nuovo nella webmail e scansionalo di nuovo.",
|
||||
"err_code_expired_or_used_title": "Questo codice è scaduto o è già stato usato",
|
||||
"err_code_expired_title": "Questo codice è scaduto",
|
||||
"err_code_invalid_detail": "Mostra un nuovo codice nella webmail (Impostazioni → Sicurezza → Collega l'app mobile) e scansionalo.",
|
||||
"err_code_invalid_title": "Questo codice non è valido",
|
||||
"err_code_used_detail": "Ogni codice funziona una sola volta. Mostrane uno nuovo nella webmail e scansionalo di nuovo.",
|
||||
"err_code_used_title": "Questo codice è già stato usato",
|
||||
"err_generic_detail": "Si è verificato un errore. Riprova.",
|
||||
"err_generic_title": "Accesso non riuscito",
|
||||
"err_interrupted_detail": "La risposta non corrisponde alla richiesta che abbiamo avviato. Prova ad accedere di nuovo.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Nessun server di posta su {host}",
|
||||
"err_no_webmail_detail": "Questo server non ha una webmail Bulwark né l'accesso OAuth. Usa una password (o una password per le app).",
|
||||
"err_no_webmail_title": "Nessuna pagina di accesso su {host}",
|
||||
"err_pair_connect_detail": "Mostra un nuovo codice sul computer e scansionalo di nuovo.",
|
||||
"err_pair_connect_title": "Accesso eseguito, ma impossibile connettersi a {host}",
|
||||
"err_pair_insecure_detail": "{host} vuole usare una connessione non cifrata.",
|
||||
"err_pair_network_detail": "Controlla la connessione e che il telefono possa raggiungere la webmail, poi riprova.",
|
||||
"err_pair_rate_detail": "Attendi un minuto, poi riprova.",
|
||||
"err_pair_server_detail": "Riprova con un nuovo codice. Se continua a non funzionare, rivolgiti all'amministratore.",
|
||||
"err_pair_server_title": "La webmail non è riuscita a completare l'accesso",
|
||||
"err_pair_unsupported_detail": "{host} non supporta i codici di accesso. Potrebbe servire un aggiornamento, oppure il link punta all'indirizzo sbagliato.",
|
||||
"err_pair_unsupported_title": "Questa webmail non può collegare dispositivi",
|
||||
"err_pair_untrusted_detail": "{host} ha inviato dati di accesso per un server che l'app non può verificare.",
|
||||
"err_pair_untrusted_title": "Questo accesso non è affidabile",
|
||||
"err_rate_detail": "Il server ci ha chiesto di attendere un momento. Riprova tra poco.",
|
||||
"err_rate_detail_seconds": "Il server ci ha chiesto di attendere. Riprova tra circa {seconds} secondi.",
|
||||
"err_rate_title": "Troppi tentativi",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Conosco l'indirizzo del mio server",
|
||||
"known_server_desc": "Conosciamo già questo server, quindi non c'è nulla da configurare",
|
||||
"known_server_title": "Un altro account su {host}",
|
||||
"link_confirm_connect": "Un link chiede di accedere su questa pagina. Continua solo se usi {host} per la tua posta.",
|
||||
"link_confirm_connect_add": "Un link chiede di aggiungere un account accedendo su questa pagina. Continua solo se usi {host} per la tua posta.",
|
||||
"link_confirm_connect_title": "Aprire la pagina di accesso di {host}?",
|
||||
"link_confirm_pair": "Questo link ti fa accedere a un account di {host}. Continua solo se hai appena mostrato un codice di accesso sul computer.",
|
||||
"link_confirm_pair_add": "Questo link aggiunge un account di {host} e passa l'app a quell'account. Continua solo se hai appena mostrato un codice di accesso sul computer.",
|
||||
"link_confirm_title": "Accedere con un link?",
|
||||
"looking_up": "Ricerca del tuo server…",
|
||||
"manual": "Inserisci i dati del server manualmente",
|
||||
"notice_bad_qr": "Questo codice non è un codice di accesso Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "メールアドレスを入力してください",
|
||||
"email_step_title_add": "もう一つのメールアドレスを入力してください",
|
||||
"email_title": "メールアドレスを使用",
|
||||
"err_account_limit_detail": "別のアカウントを追加するには、設定でアカウントを削除してください。",
|
||||
"err_bad_detail": "メールアドレスとパスワードを確認してください。アカウントで二要素認証を使用している場合は、ウェブメールでアプリパスワードを作成し、ここで使用してください。",
|
||||
"err_bad_title": "サインインできませんでした",
|
||||
"err_cert_detail": "サーバーのセキュリティ証明書が拒否されました。ご自身のサーバーの場合は、証明書が有効で期限切れでないことを確認してください。",
|
||||
"err_cert_title": "{host}を検証できませんでした",
|
||||
"err_code_expired_detail": "サインインコードの有効期間は数分です。ウェブメールで新しいコードを生成して、もう一度スキャンしてください。",
|
||||
"err_code_expired_detail": "サインインコードの有効期間は2分です。ウェブメールで新しいコードを表示して、もう一度スキャンしてください。",
|
||||
"err_code_expired_or_used_detail": "ウェブメールで新しいコードを表示して、もう一度スキャンしてください。",
|
||||
"err_code_expired_or_used_title": "このコードは期限切れか、すでに使用されています",
|
||||
"err_code_expired_title": "コードの有効期限が切れています",
|
||||
"err_code_invalid_detail": "ウェブメールで新しいコードを表示し(「設定 → セキュリティ → モバイルアプリを連携」)、スキャンしてください。",
|
||||
"err_code_invalid_title": "このコードは無効です",
|
||||
"err_code_used_detail": "各コードは1回しか使えません。ウェブメールで新しいコードを表示して、もう一度スキャンしてください。",
|
||||
"err_code_used_title": "このコードはすでに使用されています",
|
||||
"err_generic_detail": "問題が発生しました。もう一度お試しください。",
|
||||
"err_generic_title": "サインインに失敗しました",
|
||||
"err_interrupted_detail": "応答が開始したリクエストと一致しませんでした。もう一度サインインしてください。",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "{host}にメールサーバーがありません",
|
||||
"err_no_webmail_detail": "このサーバーには Bulwark ウェブメールも OAuth サインインもありません。代わりにパスワード(またはアプリパスワード)を使用してください。",
|
||||
"err_no_webmail_title": "{host}にサインインページがありません",
|
||||
"err_pair_connect_detail": "コンピューターで新しいコードを表示して、もう一度スキャンしてください。",
|
||||
"err_pair_connect_title": "サインインしましたが、{host} に接続できませんでした",
|
||||
"err_pair_insecure_detail": "{host} が暗号化されていない接続を使おうとしています。",
|
||||
"err_pair_network_detail": "接続状況と、スマートフォンからウェブメールにアクセスできるかを確認して、もう一度お試しください。",
|
||||
"err_pair_rate_detail": "1分待ってから、もう一度お試しください。",
|
||||
"err_pair_server_detail": "新しいコードでもう一度お試しください。それでも失敗する場合は、管理者にお問い合わせください。",
|
||||
"err_pair_server_title": "ウェブメールでサインインを完了できませんでした",
|
||||
"err_pair_unsupported_detail": "{host} はサインインコードに対応していません。アップデートが必要か、リンクのアドレスが間違っている可能性があります。",
|
||||
"err_pair_unsupported_title": "このウェブメールはデバイスを連携できません",
|
||||
"err_pair_untrusted_detail": "{host} から、アプリが検証できないサーバー用のサインイン情報が送られてきました。",
|
||||
"err_pair_untrusted_title": "このサインインは信頼できません",
|
||||
"err_rate_detail": "サーバーから少し待つよう求められました。しばらくしてからもう一度お試しください。",
|
||||
"err_rate_detail_seconds": "サーバーから待機するよう求められました。約{seconds}秒後にもう一度お試しください。",
|
||||
"err_rate_title": "試行回数が多すぎます",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "サーバーアドレスがわかる",
|
||||
"known_server_desc": "このサーバーは登録済みのため、設定は不要です",
|
||||
"known_server_title": "{host}の別のアカウント",
|
||||
"link_confirm_connect": "リンクがこのページでのサインインを求めています。メールに {host} を使っている場合にのみ続行してください。",
|
||||
"link_confirm_connect_add": "リンクがこのページでサインインしてアカウントを追加するよう求めています。メールに {host} を使っている場合にのみ続行してください。",
|
||||
"link_confirm_connect_title": "{host} のサインインページを開きますか?",
|
||||
"link_confirm_pair": "このリンクは {host} のアカウントにサインインします。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。",
|
||||
"link_confirm_pair_add": "このリンクは {host} のアカウントを追加し、アプリをそのアカウントに切り替えます。パソコンでサインインコードを表示したばかりの場合にのみ続行してください。",
|
||||
"link_confirm_title": "リンクでサインインしますか?",
|
||||
"looking_up": "サーバーを検索しています…",
|
||||
"manual": "サーバー情報を手動で入力",
|
||||
"notice_bad_qr": "このコードは Bulwark のサインインコードではありません",
|
||||
@@ -505,6 +529,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": "「設定 → セキュリティ → モバイルアプリを連携」でコードが表示されます。カメラをコードに向けてください。",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "ブラウザーで入力するものと同じアドレスです。https:// は自動で追加されます。",
|
||||
"server_label": "サーバーアドレス",
|
||||
"server_not_found": "{domain}のサーバーが見つかりませんでした。セルフホストしている場合は、ウェブメールで使用しているアドレスを入力してください。",
|
||||
"server_paste_hint": "代わりにサインインリンクをお持ちですか?ここに貼り付けてください。",
|
||||
"server_scan_hint": "わからない場合は、代わりにサインインコードをスキャンしてください。コードにサーバーアドレスが含まれています。",
|
||||
"server_subtitle": "ウェブメールへのアクセスに使用しているアドレスを入力してください。",
|
||||
"server_title": "サーバーアドレスを入力してください",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "이메일 주소가 무엇인가요?",
|
||||
"email_step_title_add": "다른 주소는 무엇인가요?",
|
||||
"email_title": "내 이메일 주소 사용",
|
||||
"err_account_limit_detail": "다른 계정을 추가하려면 설정에서 계정을 삭제하세요.",
|
||||
"err_bad_detail": "이메일과 비밀번호를 확인해 주세요. 계정에 2단계 인증을 사용 중이라면 웹메일에서 앱 비밀번호를 만들어 여기에 입력해 주세요.",
|
||||
"err_bad_title": "로그인하지 못했어요",
|
||||
"err_cert_detail": "서버의 보안 인증서가 거부되었어요. 직접 운영하는 서버라면 인증서가 유효하고 만료되지 않았는지 확인해 주세요.",
|
||||
"err_cert_title": "{host}을(를) 확인할 수 없어요",
|
||||
"err_code_expired_detail": "로그인 코드는 몇 분 동안만 유효해요. 웹메일에서 새 코드를 생성한 후 다시 스캔해 주세요.",
|
||||
"err_code_expired_detail": "로그인 코드는 2분 동안 유효해요. 웹메일에서 새 코드를 표시한 후 다시 스캔해 주세요.",
|
||||
"err_code_expired_or_used_detail": "웹메일에서 새 코드를 표시한 후 다시 스캔해 주세요.",
|
||||
"err_code_expired_or_used_title": "만료되었거나 이미 사용된 코드예요",
|
||||
"err_code_expired_title": "코드가 만료되었어요",
|
||||
"err_code_invalid_detail": "웹메일에서 새 코드를 표시한 후(설정 → 보안 → 모바일 앱 연결) 스캔해 주세요.",
|
||||
"err_code_invalid_title": "유효하지 않은 코드예요",
|
||||
"err_code_used_detail": "각 코드는 한 번만 사용할 수 있어요. 웹메일에서 새 코드를 표시한 후 다시 스캔해 주세요.",
|
||||
"err_code_used_title": "이미 사용된 코드예요",
|
||||
"err_generic_detail": "문제가 발생했어요. 다시 시도해 주세요.",
|
||||
"err_generic_title": "로그인 실패",
|
||||
"err_interrupted_detail": "응답이 시작한 요청과 일치하지 않아요. 다시 로그인해 주세요.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "{host}에 메일 서버가 없어요",
|
||||
"err_no_webmail_detail": "이 서버에는 Bulwark 웹메일과 OAuth 로그인이 없어요. 대신 비밀번호(또는 앱 비밀번호)를 사용해 주세요.",
|
||||
"err_no_webmail_title": "{host}에 로그인 페이지가 없어요",
|
||||
"err_pair_connect_detail": "컴퓨터에서 새 코드를 표시한 후 다시 스캔해 주세요.",
|
||||
"err_pair_connect_title": "로그인했지만 {host}에 연결하지 못했어요",
|
||||
"err_pair_insecure_detail": "{host}에서 암호화되지 않은 연결을 사용하려고 해요.",
|
||||
"err_pair_network_detail": "연결 상태와 휴대폰에서 웹메일에 접속할 수 있는지 확인한 후 다시 시도해 주세요.",
|
||||
"err_pair_rate_detail": "1분 정도 기다린 후 다시 시도해 주세요.",
|
||||
"err_pair_server_detail": "새 코드로 다시 시도해 주세요. 계속 실패하면 관리자에게 문의해 주세요.",
|
||||
"err_pair_server_title": "웹메일에서 로그인을 완료하지 못했어요",
|
||||
"err_pair_unsupported_detail": "{host}에서 로그인 코드를 지원하지 않아요. 업데이트가 필요하거나 링크가 잘못된 주소를 가리키고 있을 수 있어요.",
|
||||
"err_pair_unsupported_title": "이 웹메일은 기기를 연결할 수 없어요",
|
||||
"err_pair_untrusted_detail": "{host}에서 앱이 확인할 수 없는 서버의 로그인 정보를 보냈어요.",
|
||||
"err_pair_untrusted_title": "이 로그인은 신뢰할 수 없어요",
|
||||
"err_rate_detail": "서버에서 잠시 기다려 달라고 요청했어요. 잠시 후 다시 시도해 주세요.",
|
||||
"err_rate_detail_seconds": "서버에서 기다려 달라고 요청했어요. 약 {seconds}초 후에 다시 시도해 주세요.",
|
||||
"err_rate_title": "시도 횟수가 너무 많아요",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "서버 주소를 알고 있어요",
|
||||
"known_server_desc": "이미 알고 있는 서버라서 설정이 필요 없어요",
|
||||
"known_server_title": "{host}의 다른 계정",
|
||||
"link_confirm_connect": "링크가 이 페이지에서 로그인하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.",
|
||||
"link_confirm_connect_add": "링크가 이 페이지에서 로그인해 계정을 추가하도록 요청하고 있어요. 메일에 {host}을(를) 사용하는 경우에만 계속하세요.",
|
||||
"link_confirm_connect_title": "{host}의 로그인 페이지를 열까요?",
|
||||
"link_confirm_pair": "이 링크는 {host}의 계정으로 로그인해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.",
|
||||
"link_confirm_pair_add": "이 링크는 {host}의 계정을 추가하고 앱을 해당 계정으로 전환해요. 방금 컴퓨터에서 로그인 코드를 표시한 경우에만 계속하세요.",
|
||||
"link_confirm_title": "링크로 로그인할까요?",
|
||||
"looking_up": "서버를 찾는 중…",
|
||||
"manual": "서버 정보 직접 입력",
|
||||
"notice_bad_qr": "Bulwark 로그인 코드가 아니에요",
|
||||
@@ -505,6 +529,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": "설정 → 보안 → 모바일 앱 연결에서 코드가 표시돼요. 카메라를 코드에 비춰 주세요.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "브라우저에 입력하는 주소와 같아요. https://는 자동으로 추가돼요.",
|
||||
"server_label": "서버 주소",
|
||||
"server_not_found": "{domain}의 서버를 찾을 수 없어요. 직접 호스팅하는 경우 웹메일에 사용하는 주소를 입력해 주세요.",
|
||||
"server_paste_hint": "로그인 링크가 있나요? 여기에 붙여넣어 주세요.",
|
||||
"server_scan_hint": "잘 모르겠다면 대신 로그인 코드를 스캔해 주세요. 코드에 서버 주소가 포함되어 있어요.",
|
||||
"server_subtitle": "웹메일에 접속할 때 사용하는 주소를 입력해 주세요.",
|
||||
"server_title": "서버 주소가 무엇인가요?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Kāda ir jūsu e-pasta adrese?",
|
||||
"email_step_title_add": "Kāda ir otra adrese?",
|
||||
"email_title": "Izmantot manu e-pasta adresi",
|
||||
"err_account_limit_detail": "Lai pievienotu citu kontu, noņemiet kādu kontu iestatījumos.",
|
||||
"err_bad_detail": "Pārbaudiet e-pasta adresi un paroli. Ja jūsu kontā tiek izmantota divpakāpju pierakstīšanās, izveidojiet lietotnes paroli tīmekļa pastā un izmantojiet to šeit.",
|
||||
"err_bad_title": "Tas neizdevās",
|
||||
"err_cert_detail": "Servera drošības sertifikāts tika noraidīts. Ja tas ir jūsu serveris, pārbaudiet, vai sertifikāts ir derīgs un tā termiņš nav beidzies.",
|
||||
"err_cert_title": "Neizdevās pārbaudīt: {host}",
|
||||
"err_code_expired_detail": "Pierakstīšanās kodi ir derīgi dažas minūtes. Izveidojiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
|
||||
"err_code_expired_detail": "Pierakstīšanās kodi ir derīgi divas minūtes. Parādiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
|
||||
"err_code_expired_or_used_detail": "Parādiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
|
||||
"err_code_expired_or_used_title": "Šī koda derīgums ir beidzies, vai tas jau ir izmantots",
|
||||
"err_code_expired_title": "Šī koda derīgums ir beidzies",
|
||||
"err_code_invalid_detail": "Parādiet jaunu kodu tīmekļa pastā (Iestatījumi → Drošība → Saistīt mobilo lietotni) un noskenējiet to.",
|
||||
"err_code_invalid_title": "Šis kods nav derīgs",
|
||||
"err_code_used_detail": "Katrs kods darbojas tikai vienu reizi. Parādiet jaunu tīmekļa pastā un skenējiet vēlreiz.",
|
||||
"err_code_used_title": "Šis kods jau ir izmantots",
|
||||
"err_generic_detail": "Kaut kas nogāja greizi. Mēģiniet vēlreiz.",
|
||||
"err_generic_title": "Pierakstīšanās neizdevās",
|
||||
"err_interrupted_detail": "Atbilde neatbilda sāktajam pieprasījumam. Mēģiniet pierakstīties vēlreiz.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Pasta serveris nav atrasts: {host}",
|
||||
"err_no_webmail_detail": "Šim serverim nav Bulwark tīmekļa pasta un OAuth pierakstīšanās. Tā vietā izmantojiet paroli (vai lietotnes paroli).",
|
||||
"err_no_webmail_title": "Pierakstīšanās lapa nav atrasta: {host}",
|
||||
"err_pair_connect_detail": "Parādiet jaunu kodu datorā un skenējiet vēlreiz.",
|
||||
"err_pair_connect_title": "Pierakstījāties, bet neizdevās izveidot savienojumu ar {host}",
|
||||
"err_pair_insecure_detail": "{host} vēlas izmantot nešifrētu savienojumu.",
|
||||
"err_pair_network_detail": "Pārbaudiet savienojumu un to, vai tālrunis var piekļūt tīmekļa pastam, un mēģiniet vēlreiz.",
|
||||
"err_pair_rate_detail": "Uzgaidiet minūti un mēģiniet vēlreiz.",
|
||||
"err_pair_server_detail": "Mēģiniet vēlreiz ar jaunu kodu. Ja tas joprojām neizdodas, sazinieties ar administratoru.",
|
||||
"err_pair_server_title": "Tīmekļa pasts nevarēja pabeigt pierakstīšanos",
|
||||
"err_pair_unsupported_detail": "{host} neatbalsta pierakstīšanās kodus. Iespējams, tam nepieciešams atjauninājums, vai saite norāda uz nepareizu adresi.",
|
||||
"err_pair_unsupported_title": "Šis tīmekļa pasts nevar saistīt ierīces",
|
||||
"err_pair_untrusted_detail": "{host} nosūtīja pierakstīšanās datus serverim, ko lietotne nevar pārbaudīt.",
|
||||
"err_pair_untrusted_title": "Šai pierakstīšanās reizei nevar uzticēties",
|
||||
"err_rate_detail": "Serveris lūdza brīdi uzgaidīt. Drīz mēģiniet vēlreiz.",
|
||||
"err_rate_detail_seconds": "Serveris lūdza uzgaidīt. Mēģiniet vēlreiz pēc aptuveni {seconds} s.",
|
||||
"err_rate_title": "Pārāk daudz mēģinājumu",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Es zinu sava servera adresi",
|
||||
"known_server_desc": "Šis serveris mums jau ir zināms, tāpēc nekas nav jāiestata",
|
||||
"known_server_title": "Vēl viens konts: {host}",
|
||||
"link_confirm_connect": "Saite aicina pierakstīties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.",
|
||||
"link_confirm_connect_add": "Saite aicina pievienot kontu, pierakstoties šajā lapā. Turpiniet tikai tad, ja izmantojat {host} savam e-pastam.",
|
||||
"link_confirm_connect_title": "Atvērt {host} pierakstīšanās lapu?",
|
||||
"link_confirm_pair": "Šī saite pierakstās kontā no {host}. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.",
|
||||
"link_confirm_pair_add": "Šī saite pievieno kontu no {host} un pārslēdz lietotni uz to. Turpiniet tikai tad, ja tikko datorā parādījāt pierakstīšanās kodu.",
|
||||
"link_confirm_title": "Pierakstīties ar saiti?",
|
||||
"looking_up": "Meklē jūsu serveri…",
|
||||
"manual": "Ievadīt servera datus manuāli",
|
||||
"notice_bad_qr": "Šis nav Bulwark pierakstīšanās kods",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Таны имэйл хаяг юу вэ?",
|
||||
"email_step_title_add": "Нөгөө хаяг юу вэ?",
|
||||
"email_title": "Имэйл хаягаа ашиглах",
|
||||
"err_account_limit_detail": "Өөр бүртгэл нэмэхийн тулд Тохиргооноос нэг бүртгэлийг хасна уу.",
|
||||
"err_bad_detail": "Имэйл болон нууц үгээ шалгана уу. Хэрэв таны бүртгэл хоёр шаттай нэвтрэлт ашигладаг бол вэбмэйлд аппын нууц үг үүсгээд энд ашиглана уу.",
|
||||
"err_bad_title": "Амжилтгүй боллоо",
|
||||
"err_cert_detail": "Серверийн аюулгүй байдлын гэрчилгээг татгалзсан. Хэрэв энэ таны өөрийн сервер бол гэрчилгээ хүчинтэй, хугацаа нь дуусаагүй эсэхийг шалгана уу.",
|
||||
"err_cert_title": "Баталгаажуулж чадсангүй: {host}",
|
||||
"err_code_expired_detail": "Нэвтрэх код хэдхэн минут хүчинтэй. Вэбмэйлд шинэ код үүсгээд дахин скан хийнэ үү.",
|
||||
"err_code_expired_detail": "Нэвтрэх код хоёр минут хүчинтэй. Вэбмэйлд шинэ код харуулаад дахин скан хийнэ үү.",
|
||||
"err_code_expired_or_used_detail": "Вэбмэйлд шинэ код харуулаад дахин скан хийнэ үү.",
|
||||
"err_code_expired_or_used_title": "Энэ кодын хугацаа дууссан эсвэл аль хэдийн ашиглагдсан",
|
||||
"err_code_expired_title": "Кодын хугацаа дууссан",
|
||||
"err_code_invalid_detail": "Вэбмэйлд шинэ код харуулаад (Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох) скан хийнэ үү.",
|
||||
"err_code_invalid_title": "Энэ код хүчингүй байна",
|
||||
"err_code_used_detail": "Код бүр зөвхөн нэг удаа ажиллана. Вэбмэйлд шинэ код харуулаад дахин скан хийнэ үү.",
|
||||
"err_code_used_title": "Энэ код аль хэдийн ашиглагдсан",
|
||||
"err_generic_detail": "Ямар нэг алдаа гарлаа. Дахин оролдоно уу.",
|
||||
"err_generic_title": "Нэвтэрч чадсангүй",
|
||||
"err_interrupted_detail": "Хариу нь бидний эхлүүлсэн хүсэлттэй таарахгүй байна. Дахин нэвтэрч үзнэ үү.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Мэйл сервер алга: {host}",
|
||||
"err_no_webmail_detail": "Энэ серверт Bulwark вэбмэйл болон OAuth нэвтрэлт байхгүй. Оронд нь нууц үг (эсвэл аппын нууц үг) ашиглана уу.",
|
||||
"err_no_webmail_title": "Нэвтрэх хуудас алга: {host}",
|
||||
"err_pair_connect_detail": "Компьютер дээрээ шинэ код харуулаад дахин скан хийнэ үү.",
|
||||
"err_pair_connect_title": "Нэвтэрсэн ч {host} руу холбогдож чадсангүй",
|
||||
"err_pair_insecure_detail": "{host} шифрлэгдээгүй холболт ашиглахыг хүсэж байна.",
|
||||
"err_pair_network_detail": "Холболтоо болон утас тань вэбмэйлд хүрч чадаж байгаа эсэхийг шалгаад дахин оролдоно уу.",
|
||||
"err_pair_rate_detail": "Нэг минут хүлээгээд дахин оролдоно уу.",
|
||||
"err_pair_server_detail": "Шинэ кодоор дахин оролдоно уу. Алдаа үргэлжилбэл админтайгаа холбогдоно уу.",
|
||||
"err_pair_server_title": "Вэбмэйл нэвтрэлтийг дуусгаж чадсангүй",
|
||||
"err_pair_unsupported_detail": "{host} нэвтрэх кодыг дэмждэггүй. Шинэчлэл шаардлагатай байж магадгүй, эсвэл холбоос буруу хаяг руу заасан байна.",
|
||||
"err_pair_unsupported_title": "Энэ вэбмэйл төхөөрөмж холбох боломжгүй",
|
||||
"err_pair_untrusted_detail": "{host} програмын баталгаажуулж чадахгүй серверийн нэвтрэх мэдээллийг илгээсэн.",
|
||||
"err_pair_untrusted_title": "Энэ нэвтрэлтэд итгэх боломжгүй",
|
||||
"err_rate_detail": "Сервер түр хүлээхийг хүслээ. Удахгүй дахин оролдоно уу.",
|
||||
"err_rate_detail_seconds": "Сервер хүлээхийг хүслээ. Ойролцоогоор {seconds} секундын дараа дахин оролдоно уу.",
|
||||
"err_rate_title": "Хэт олон оролдлого",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Би серверийн хаягаа мэднэ",
|
||||
"known_server_desc": "Бид энэ серверийг аль хэдийн мэддэг тул тохируулах шаардлагагүй",
|
||||
"known_server_title": "{host} дээрх өөр бүртгэл",
|
||||
"link_confirm_connect": "Холбоос энэ хуудсанд нэвтрэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.",
|
||||
"link_confirm_connect_add": "Холбоос энэ хуудсанд нэвтэрч бүртгэл нэмэхийг хүсэж байна. Та имэйлдээ {host}-ийг ашигладаг бол л үргэлжлүүлнэ үү.",
|
||||
"link_confirm_connect_title": "{host}-ийн нэвтрэх хуудсыг нээх үү?",
|
||||
"link_confirm_pair": "Энэ холбоос {host} дээрх бүртгэлд нэвтрүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.",
|
||||
"link_confirm_pair_add": "Энэ холбоос {host} дээрх бүртгэлийг нэмээд програмыг түүн рүү шилжүүлнэ. Та саяхан компьютер дээрээ нэвтрэх код харуулсан бол л үргэлжлүүлнэ үү.",
|
||||
"link_confirm_title": "Холбоосоор нэвтрэх үү?",
|
||||
"looking_up": "Таны серверийг хайж байна…",
|
||||
"manual": "Серверийн мэдээллийг гараар оруулах",
|
||||
"notice_bad_qr": "Энэ код Bulwark-ийн нэвтрэх код биш байна",
|
||||
@@ -505,6 +529,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": "Тохиргоо → Аюулгүй байдал → Мобайл програмыг холбох хэсэгт код гарна. Камераа түүн рүү чиглүүлнэ үү.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "Хөтөч дээр бичдэг хаяг. https://-г бид нэмнэ.",
|
||||
"server_label": "Серверийн хаяг",
|
||||
"server_not_found": "{domain} домэйны сервер олдсонгүй. Хэрэв та өөрөө сервертэй бол вэбмэйлд ашигладаг хаягаа оруулна уу.",
|
||||
"server_paste_hint": "Оронд нь нэвтрэх холбоос байгаа юу? Энд буулгана уу.",
|
||||
"server_scan_hint": "Итгэлгүй байвал оронд нь нэвтрэх код скан хийнэ үү. Үүнд серверийн хаяг агуулагддаг.",
|
||||
"server_subtitle": "Вэбмэйл рүүгээ ороход ашигладаг хаягаа оруулна уу.",
|
||||
"server_title": "Таны серверийн хаяг юу вэ?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Hva er e-postadressen din?",
|
||||
"email_step_title_add": "Hva er den andre adressen?",
|
||||
"email_title": "Bruk e-postadressen min",
|
||||
"err_account_limit_detail": "Fjern en konto i Innstillinger for å legge til en ny.",
|
||||
"err_bad_detail": "Sjekk e-postadressen og passordet. Hvis kontoen din bruker tofaktorautentisering, oppretter du et app-passord i nettposten og bruker det her.",
|
||||
"err_bad_title": "Det fungerte ikke",
|
||||
"err_cert_detail": "Serverens sikkerhetssertifikat ble avvist. Hvis dette er din egen server, må du sjekke at sertifikatet er gyldig og ikke utløpt.",
|
||||
"err_cert_title": "Kunne ikke bekrefte {host}",
|
||||
"err_code_expired_detail": "Påloggingskoder er bare gyldige i noen få minutter. Lag en ny i nettposten, og skann på nytt.",
|
||||
"err_code_expired_detail": "Påloggingskoder er gyldige i to minutter. Vis en ny i nettposten, og skann på nytt.",
|
||||
"err_code_expired_or_used_detail": "Vis en ny i nettposten, og skann på nytt.",
|
||||
"err_code_expired_or_used_title": "Koden er utløpt eller allerede brukt",
|
||||
"err_code_expired_title": "Koden er utløpt",
|
||||
"err_code_invalid_detail": "Vis en ny kode i nettposten (Innstillinger → Sikkerhet → Koble til mobilappen), og skann den.",
|
||||
"err_code_invalid_title": "Koden er ikke gyldig",
|
||||
"err_code_used_detail": "Hver kode virker bare én gang. Vis en ny i nettposten, og skann på nytt.",
|
||||
"err_code_used_title": "Koden er allerede brukt",
|
||||
"err_generic_detail": "Noe gikk galt. Prøv igjen.",
|
||||
"err_generic_title": "Påloggingen mislyktes",
|
||||
"err_interrupted_detail": "Svaret samsvarte ikke med forespørselen vi startet. Prøv å logge på igjen.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Ingen e-postserver på {host}",
|
||||
"err_no_webmail_detail": "Denne serveren har verken Bulwark-nettpost eller OAuth-pålogging. Bruk et passord (eller et app-passord) i stedet.",
|
||||
"err_no_webmail_title": "Ingen påloggingsside på {host}",
|
||||
"err_pair_connect_detail": "Vis en ny kode på datamaskinen, og skann på nytt.",
|
||||
"err_pair_connect_title": "Pålogget, men kunne ikke koble til {host}",
|
||||
"err_pair_insecure_detail": "{host} vil bruke en ukryptert tilkobling.",
|
||||
"err_pair_network_detail": "Sjekk tilkoblingen og at telefonen kan nå nettposten, og prøv deretter på nytt.",
|
||||
"err_pair_rate_detail": "Vent et minutt, og prøv deretter på nytt.",
|
||||
"err_pair_server_detail": "Prøv på nytt med en ny kode. Hvis det fortsatt mislykkes, kontakt administratoren.",
|
||||
"err_pair_server_title": "Nettposten kunne ikke fullføre påloggingen",
|
||||
"err_pair_unsupported_detail": "{host} støtter ikke påloggingskoder. Den må kanskje oppdateres, eller lenken peker til feil adresse.",
|
||||
"err_pair_unsupported_title": "Denne nettposten kan ikke koble til enheter",
|
||||
"err_pair_untrusted_detail": "{host} sendte påloggingsinformasjon for en server appen ikke kan bekrefte.",
|
||||
"err_pair_untrusted_title": "Denne påloggingen kan ikke stoles på",
|
||||
"err_rate_detail": "Serveren ba oss vente litt. Prøv igjen om et øyeblikk.",
|
||||
"err_rate_detail_seconds": "Serveren ba oss vente. Prøv igjen om omtrent {seconds} sekunder.",
|
||||
"err_rate_title": "For mange forsøk",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Jeg kjenner serveradressen min",
|
||||
"known_server_desc": "Vi kjenner allerede denne serveren, så du trenger ikke å sette opp noe",
|
||||
"known_server_title": "En annen konto på {host}",
|
||||
"link_confirm_connect": "En lenke ber om å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.",
|
||||
"link_confirm_connect_add": "En lenke ber om å legge til en konto ved å logge på på denne siden. Fortsett bare hvis du bruker {host} til e-posten din.",
|
||||
"link_confirm_connect_title": "Åpne påloggingssiden til {host}?",
|
||||
"link_confirm_pair": "Denne lenken logger på en konto fra {host}. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.",
|
||||
"link_confirm_pair_add": "Denne lenken legger til en konto fra {host} og bytter appen til den. Fortsett bare hvis du nettopp viste en påloggingskode på datamaskinen.",
|
||||
"link_confirm_title": "Logge på med en lenke?",
|
||||
"looking_up": "Slår opp serveren din…",
|
||||
"manual": "Skriv inn serverdetaljer manuelt",
|
||||
"notice_bad_qr": "Koden er ikke en påloggingskode for Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Wat is uw e-mailadres?",
|
||||
"email_step_title_add": "Wat is het andere adres?",
|
||||
"email_title": "Mijn e-mailadres gebruiken",
|
||||
"err_account_limit_detail": "Verwijder een account in Instellingen om er nog een toe te voegen.",
|
||||
"err_bad_detail": "Controleer uw e-mailadres en wachtwoord. Als uw account tweefactorauthenticatie gebruikt, maak dan in de webmail een app-wachtwoord aan en gebruik dat hier.",
|
||||
"err_bad_title": "Dat is niet gelukt",
|
||||
"err_cert_detail": "Het beveiligingscertificaat van de server is geweigerd. Als dit uw eigen server is, controleer dan of het certificaat geldig en niet verlopen is.",
|
||||
"err_cert_title": "Kan {host} niet verifiëren",
|
||||
"err_code_expired_detail": "Inlogcodes zijn maar een paar minuten geldig. Genereer een nieuwe in de webmail en scan opnieuw.",
|
||||
"err_code_expired_detail": "Inlogcodes zijn twee minuten geldig. Toon een nieuwe in de webmail en scan opnieuw.",
|
||||
"err_code_expired_or_used_detail": "Toon een nieuwe in de webmail en scan opnieuw.",
|
||||
"err_code_expired_or_used_title": "Die code is verlopen of al gebruikt",
|
||||
"err_code_expired_title": "Die code is verlopen",
|
||||
"err_code_invalid_detail": "Toon een nieuwe code in de webmail (Instellingen → Beveiliging → Mobiele app koppelen) en scan die.",
|
||||
"err_code_invalid_title": "Die code is niet geldig",
|
||||
"err_code_used_detail": "Elke code werkt maar één keer. Toon een nieuwe in de webmail en scan opnieuw.",
|
||||
"err_code_used_title": "Die code is al gebruikt",
|
||||
"err_generic_detail": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
"err_generic_title": "Inloggen mislukt",
|
||||
"err_interrupted_detail": "Het antwoord kwam niet overeen met het gestarte verzoek. Probeer opnieuw in te loggen.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Geen mailserver op {host}",
|
||||
"err_no_webmail_detail": "Deze server heeft geen Bulwark-webmail en geen OAuth-aanmelding. Gebruik in plaats daarvan een wachtwoord (of een app-wachtwoord).",
|
||||
"err_no_webmail_title": "Geen inlogpagina op {host}",
|
||||
"err_pair_connect_detail": "Toon een nieuwe code op uw computer en scan opnieuw.",
|
||||
"err_pair_connect_title": "Ingelogd, maar kan geen verbinding maken met {host}",
|
||||
"err_pair_insecure_detail": "{host} wil een onversleutelde verbinding gebruiken.",
|
||||
"err_pair_network_detail": "Controleer uw verbinding en of uw telefoon de webmail kan bereiken, en probeer het opnieuw.",
|
||||
"err_pair_rate_detail": "Wacht een minuut en probeer het dan opnieuw.",
|
||||
"err_pair_server_detail": "Probeer het opnieuw met een nieuwe code. Blijft het mislukken, neem dan contact op met uw beheerder.",
|
||||
"err_pair_server_title": "De webmail kon het inloggen niet voltooien",
|
||||
"err_pair_unsupported_detail": "{host} ondersteunt geen inlogcodes. Mogelijk is een update nodig, of de link verwijst naar het verkeerde adres.",
|
||||
"err_pair_unsupported_title": "Deze webmail kan geen apparaten koppelen",
|
||||
"err_pair_untrusted_detail": "{host} heeft inloggegevens gestuurd voor een server die de app niet kan verifiëren.",
|
||||
"err_pair_untrusted_title": "Deze aanmelding kan niet worden vertrouwd",
|
||||
"err_rate_detail": "De server vroeg ons even te wachten. Probeer het zo opnieuw.",
|
||||
"err_rate_detail_seconds": "De server vroeg ons te wachten. Probeer het over ongeveer {seconds} seconden opnieuw.",
|
||||
"err_rate_title": "Te veel pogingen",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Ik ken mijn serveradres",
|
||||
"known_server_desc": "We kennen deze server al, dus er hoeft niets te worden ingesteld",
|
||||
"known_server_title": "Nog een account op {host}",
|
||||
"link_confirm_connect": "Een link vraagt om op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.",
|
||||
"link_confirm_connect_add": "Een link vraagt om een account toe te voegen door op deze pagina in te loggen. Ga alleen door als u {host} voor uw e-mail gebruikt.",
|
||||
"link_confirm_connect_title": "Inlogpagina van {host} openen?",
|
||||
"link_confirm_pair": "Deze link logt u in op een account van {host}. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.",
|
||||
"link_confirm_pair_add": "Deze link voegt een account van {host} toe en schakelt de app daarnaar over. Ga alleen door als u zojuist een inlogcode op uw computer hebt getoond.",
|
||||
"link_confirm_title": "Inloggen met een link?",
|
||||
"looking_up": "Uw server opzoeken…",
|
||||
"manual": "Servergegevens handmatig invoeren",
|
||||
"notice_bad_qr": "Die code is geen Bulwark-inlogcode",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Jaki jest Twój adres e-mail?",
|
||||
"email_step_title_add": "Jaki jest drugi adres?",
|
||||
"email_title": "Użyj mojego adresu e-mail",
|
||||
"err_account_limit_detail": "Usuń konto w Ustawieniach, aby dodać kolejne.",
|
||||
"err_bad_detail": "Sprawdź adres e-mail i hasło. Jeśli Twoje konto używa logowania dwuskładnikowego, utwórz hasło aplikacji w webmailu i użyj go tutaj.",
|
||||
"err_bad_title": "To nie zadziałało",
|
||||
"err_cert_detail": "Certyfikat bezpieczeństwa serwera został odrzucony. Jeśli to Twój własny serwer, sprawdź, czy certyfikat jest ważny i nie wygasł.",
|
||||
"err_cert_title": "Nie można zweryfikować: {host}",
|
||||
"err_code_expired_detail": "Kody logowania są ważne przez kilka minut. Wygeneruj nowy w webmailu i zeskanuj go ponownie.",
|
||||
"err_code_expired_detail": "Kody logowania są ważne przez dwie minuty. Wyświetl nowy w webmailu i zeskanuj go ponownie.",
|
||||
"err_code_expired_or_used_detail": "Wyświetl nowy w webmailu i zeskanuj go ponownie.",
|
||||
"err_code_expired_or_used_title": "Ten kod wygasł lub został już użyty",
|
||||
"err_code_expired_title": "Ten kod wygasł",
|
||||
"err_code_invalid_detail": "Wyświetl nowy kod w webmailu (Ustawienia → Bezpieczeństwo → Połącz aplikację mobilną) i zeskanuj go.",
|
||||
"err_code_invalid_title": "Ten kod jest nieprawidłowy",
|
||||
"err_code_used_detail": "Każdy kod działa tylko raz. Wyświetl nowy w webmailu i zeskanuj go ponownie.",
|
||||
"err_code_used_title": "Ten kod został już użyty",
|
||||
"err_generic_detail": "Coś poszło nie tak. Spróbuj ponownie.",
|
||||
"err_generic_title": "Logowanie nie powiodło się",
|
||||
"err_interrupted_detail": "Odpowiedź nie pasuje do rozpoczętego żądania. Spróbuj zalogować się ponownie.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Brak serwera poczty: {host}",
|
||||
"err_no_webmail_detail": "Ten serwer nie ma webmaila Bulwark ani logowania OAuth. Użyj hasła (lub hasła aplikacji).",
|
||||
"err_no_webmail_title": "Brak strony logowania: {host}",
|
||||
"err_pair_connect_detail": "Wyświetl nowy kod na komputerze i zeskanuj go ponownie.",
|
||||
"err_pair_connect_title": "Zalogowano, ale nie można połączyć się z {host}",
|
||||
"err_pair_insecure_detail": "{host} chce użyć nieszyfrowanego połączenia.",
|
||||
"err_pair_network_detail": "Sprawdź połączenie i czy telefon ma dostęp do webmaila, a potem spróbuj ponownie.",
|
||||
"err_pair_rate_detail": "Poczekaj minutę i spróbuj ponownie.",
|
||||
"err_pair_server_detail": "Spróbuj ponownie z nowym kodem. Jeśli nadal się nie udaje, skontaktuj się z administratorem.",
|
||||
"err_pair_server_title": "Webmail nie mógł dokończyć logowania",
|
||||
"err_pair_unsupported_detail": "{host} nie obsługuje kodów logowania. Może wymagać aktualizacji albo link wskazuje zły adres.",
|
||||
"err_pair_unsupported_title": "Ten webmail nie może łączyć urządzeń",
|
||||
"err_pair_untrusted_detail": "{host} przesłał dane logowania dla serwera, którego aplikacja nie może zweryfikować.",
|
||||
"err_pair_untrusted_title": "Temu logowaniu nie można zaufać",
|
||||
"err_rate_detail": "Serwer poprosił o chwilę przerwy. Spróbuj ponownie za moment.",
|
||||
"err_rate_detail_seconds": "Serwer poprosił o odczekanie. Spróbuj ponownie za około {seconds} s.",
|
||||
"err_rate_title": "Zbyt wiele prób",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Znam adres swojego serwera",
|
||||
"known_server_desc": "Ten serwer jest już znany, więc nie trzeba niczego konfigurować",
|
||||
"known_server_title": "Kolejne konto na {host}",
|
||||
"link_confirm_connect": "Link prosi o zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.",
|
||||
"link_confirm_connect_add": "Link prosi o dodanie konta przez zalogowanie się na tej stronie. Kontynuuj tylko, jeśli używasz {host} do poczty.",
|
||||
"link_confirm_connect_title": "Otworzyć stronę logowania {host}?",
|
||||
"link_confirm_pair": "Ten link loguje do konta z {host}. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.",
|
||||
"link_confirm_pair_add": "Ten link dodaje konto z {host} i przełącza na nie aplikację. Kontynuuj tylko, jeśli kod logowania został właśnie wyświetlony na Twoim komputerze.",
|
||||
"link_confirm_title": "Zalogować się za pomocą linku?",
|
||||
"looking_up": "Wyszukiwanie serwera…",
|
||||
"manual": "Wprowadź dane serwera ręcznie",
|
||||
"notice_bad_qr": "To nie jest kod logowania Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Qual é o seu endereço de e-mail?",
|
||||
"email_step_title_add": "Qual é o outro endereço?",
|
||||
"email_title": "Usar meu endereço de e-mail",
|
||||
"err_account_limit_detail": "Remova uma conta em Configurações para adicionar outra.",
|
||||
"err_bad_detail": "Verifique seu e-mail e sua senha. Se sua conta usa autenticação de dois fatores, crie uma senha de aplicativo no webmail e use-a aqui.",
|
||||
"err_bad_title": "Não funcionou",
|
||||
"err_cert_detail": "O certificado de segurança do servidor foi rejeitado. Se o servidor for seu, verifique se o certificado é válido e não expirou.",
|
||||
"err_cert_title": "Não foi possível verificar {host}",
|
||||
"err_code_expired_detail": "Os códigos de login valem por alguns minutos. Gere um novo no webmail e escaneie de novo.",
|
||||
"err_code_expired_detail": "Os códigos de login valem por dois minutos. Mostre um novo no webmail e escaneie de novo.",
|
||||
"err_code_expired_or_used_detail": "Mostre um novo no webmail e escaneie de novo.",
|
||||
"err_code_expired_or_used_title": "Esse código expirou ou já foi usado",
|
||||
"err_code_expired_title": "Esse código expirou",
|
||||
"err_code_invalid_detail": "Mostre um novo código no webmail (Configurações → Segurança → Vincular aplicativo móvel) e escaneie-o.",
|
||||
"err_code_invalid_title": "Esse código não é válido",
|
||||
"err_code_used_detail": "Cada código funciona uma única vez. Mostre um novo no webmail e escaneie de novo.",
|
||||
"err_code_used_title": "Esse código já foi usado",
|
||||
"err_generic_detail": "Algo deu errado. Tente novamente.",
|
||||
"err_generic_title": "Falha no login",
|
||||
"err_interrupted_detail": "A resposta não corresponde à solicitação que iniciamos. Tente entrar novamente.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Nenhum servidor de e-mail em {host}",
|
||||
"err_no_webmail_detail": "Este servidor não tem webmail do Bulwark nem login OAuth. Use uma senha (ou uma senha de aplicativo).",
|
||||
"err_no_webmail_title": "Nenhuma página de login em {host}",
|
||||
"err_pair_connect_detail": "Mostre um novo código no computador e escaneie de novo.",
|
||||
"err_pair_connect_title": "Login feito, mas não foi possível conectar a {host}",
|
||||
"err_pair_insecure_detail": "{host} quer usar uma conexão sem criptografia.",
|
||||
"err_pair_network_detail": "Verifique sua conexão e se o celular consegue acessar o webmail, depois tente de novo.",
|
||||
"err_pair_rate_detail": "Aguarde um minuto e tente de novo.",
|
||||
"err_pair_server_detail": "Tente de novo com um novo código. Se continuar falhando, fale com o administrador.",
|
||||
"err_pair_server_title": "O webmail não conseguiu concluir o login",
|
||||
"err_pair_unsupported_detail": "{host} não oferece suporte a códigos de login. Talvez precise de uma atualização, ou o link aponta para o endereço errado.",
|
||||
"err_pair_unsupported_title": "Este webmail não consegue vincular dispositivos",
|
||||
"err_pair_untrusted_detail": "{host} enviou dados de login para um servidor que o aplicativo não consegue verificar.",
|
||||
"err_pair_untrusted_title": "Este login não é confiável",
|
||||
"err_rate_detail": "O servidor pediu para aguardarmos um momento. Tente novamente em breve.",
|
||||
"err_rate_detail_seconds": "O servidor pediu para aguardarmos. Tente novamente em cerca de {seconds} segundos.",
|
||||
"err_rate_title": "Tentativas demais",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Sei o endereço do meu servidor",
|
||||
"known_server_desc": "Já conhecemos este servidor, então não há nada para configurar",
|
||||
"known_server_title": "Outra conta em {host}",
|
||||
"link_confirm_connect": "Um link pede para fazer login nesta página. Só continue se você usa {host} para o seu e-mail.",
|
||||
"link_confirm_connect_add": "Um link pede para adicionar uma conta fazendo login nesta página. Só continue se você usa {host} para o seu e-mail.",
|
||||
"link_confirm_connect_title": "Abrir a página de login de {host}?",
|
||||
"link_confirm_pair": "Este link faz login em uma conta de {host}. Só continue se você acabou de mostrar um código de login no computador.",
|
||||
"link_confirm_pair_add": "Este link adiciona uma conta de {host} e muda o aplicativo para ela. Só continue se você acabou de mostrar um código de login no computador.",
|
||||
"link_confirm_title": "Entrar com um link?",
|
||||
"looking_up": "Procurando seu servidor…",
|
||||
"manual": "Inserir os dados do servidor manualmente",
|
||||
"notice_bad_qr": "Esse código não é um código de login do Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Care este adresa dvs. de e-mail?",
|
||||
"email_step_title_add": "Care este cealaltă adresă?",
|
||||
"email_title": "Folosiți adresa mea de e-mail",
|
||||
"err_account_limit_detail": "Eliminați un cont din Setări pentru a adăuga altul.",
|
||||
"err_bad_detail": "Verificați adresa de e-mail și parola. Dacă contul dvs. folosește autentificarea în doi pași, creați o parolă pentru aplicații în webmail și folosiți-o aici.",
|
||||
"err_bad_title": "Nu a funcționat",
|
||||
"err_cert_detail": "Certificatul de securitate al serverului a fost respins. Dacă este serverul dvs., verificați dacă certificatul este valid și nu a expirat.",
|
||||
"err_cert_title": "Nu s-a putut verifica {host}",
|
||||
"err_code_expired_detail": "Codurile de autentificare sunt valabile câteva minute. Generați unul nou în webmail și scanați-l din nou.",
|
||||
"err_code_expired_detail": "Codurile de autentificare sunt valabile două minute. Afișați unul nou în webmail și scanați-l din nou.",
|
||||
"err_code_expired_or_used_detail": "Afișați unul nou în webmail și scanați-l din nou.",
|
||||
"err_code_expired_or_used_title": "Acest cod a expirat sau a fost deja folosit",
|
||||
"err_code_expired_title": "Acest cod a expirat",
|
||||
"err_code_invalid_detail": "Afișați un cod nou în webmail (Setări → Securitate → Aplicație mobilă) și scanați-l.",
|
||||
"err_code_invalid_title": "Acest cod nu este valid",
|
||||
"err_code_used_detail": "Fiecare cod funcționează o singură dată. Afișați unul nou în webmail și scanați-l din nou.",
|
||||
"err_code_used_title": "Acest cod a fost deja folosit",
|
||||
"err_generic_detail": "Ceva nu a funcționat. Încercați din nou.",
|
||||
"err_generic_title": "Autentificarea a eșuat",
|
||||
"err_interrupted_detail": "Răspunsul nu corespunde cererii pe care am inițiat-o. Încercați să vă autentificați din nou.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Niciun server de e-mail la {host}",
|
||||
"err_no_webmail_detail": "Acest server nu are webmail Bulwark și nici autentificare OAuth. Folosiți în schimb o parolă (sau o parolă pentru aplicații).",
|
||||
"err_no_webmail_title": "Nicio pagină de autentificare la {host}",
|
||||
"err_pair_connect_detail": "Afișați un cod nou pe computer și scanați-l din nou.",
|
||||
"err_pair_connect_title": "Autentificat, dar nu se poate conecta la {host}",
|
||||
"err_pair_insecure_detail": "{host} vrea să folosească o conexiune necriptată.",
|
||||
"err_pair_network_detail": "Verificați conexiunea și dacă telefonul poate accesa webmailul, apoi încercați din nou.",
|
||||
"err_pair_rate_detail": "Așteptați un minut, apoi încercați din nou.",
|
||||
"err_pair_server_detail": "Încercați din nou cu un cod nou. Dacă tot nu funcționează, contactați administratorul.",
|
||||
"err_pair_server_title": "Webmailul nu a putut finaliza autentificarea",
|
||||
"err_pair_unsupported_detail": "{host} nu acceptă coduri de autentificare. Poate are nevoie de o actualizare sau linkul duce la o adresă greșită.",
|
||||
"err_pair_unsupported_title": "Acest webmail nu poate asocia dispozitive",
|
||||
"err_pair_untrusted_detail": "{host} a trimis date de autentificare pentru un server pe care aplicația nu îl poate verifica.",
|
||||
"err_pair_untrusted_title": "Această autentificare nu este de încredere",
|
||||
"err_rate_detail": "Serverul ne-a cerut să așteptăm puțin. Încercați din nou în scurt timp.",
|
||||
"err_rate_detail_seconds": "Serverul ne-a cerut să așteptăm. Încercați din nou în aproximativ {seconds, plural, one {# secundă} few {# secunde} other {# de secunde}}.",
|
||||
"err_rate_title": "Prea multe încercări",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Cunosc adresa serverului meu",
|
||||
"known_server_desc": "Cunoaștem deja acest server, deci nu este nimic de configurat",
|
||||
"known_server_title": "Alt cont pe {host}",
|
||||
"link_confirm_connect": "Un link cere autentificarea pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.",
|
||||
"link_confirm_connect_add": "Un link cere adăugarea unui cont prin autentificare pe această pagină. Continuați doar dacă folosiți {host} pentru e-mail.",
|
||||
"link_confirm_connect_title": "Deschideți pagina de autentificare a {host}?",
|
||||
"link_confirm_pair": "Acest link vă autentifică într-un cont de la {host}. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.",
|
||||
"link_confirm_pair_add": "Acest link adaugă un cont de la {host} și comută aplicația pe el. Continuați doar dacă tocmai ați afișat un cod de autentificare pe computer.",
|
||||
"link_confirm_title": "Vă autentificați cu un link?",
|
||||
"looking_up": "Se caută serverul dvs.…",
|
||||
"manual": "Introduceți manual detaliile serverului",
|
||||
"notice_bad_qr": "Acesta nu este un cod de autentificare Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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.?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Какой у вас адрес электронной почты?",
|
||||
"email_step_title_add": "Какой у вас другой адрес?",
|
||||
"email_title": "Использовать мой адрес электронной почты",
|
||||
"err_account_limit_detail": "Удалите аккаунт в настройках, чтобы добавить новый.",
|
||||
"err_bad_detail": "Проверьте адрес электронной почты и пароль. Если в аккаунте включена двухфакторная аутентификация, создайте пароль приложения в веб-почте и используйте его здесь.",
|
||||
"err_bad_title": "Не получилось",
|
||||
"err_cert_detail": "Сертификат безопасности сервера отклонён. Если это ваш собственный сервер, проверьте, что сертификат действителен и не истёк.",
|
||||
"err_cert_title": "Не удалось проверить {host}",
|
||||
"err_code_expired_detail": "Коды входа действуют несколько минут. Создайте новый в веб-почте и отсканируйте его снова.",
|
||||
"err_code_expired_detail": "Коды входа действуют две минуты. Покажите новый в веб-почте и отсканируйте его снова.",
|
||||
"err_code_expired_or_used_detail": "Покажите новый в веб-почте и отсканируйте его снова.",
|
||||
"err_code_expired_or_used_title": "Срок действия кода истёк, или он уже использован",
|
||||
"err_code_expired_title": "Срок действия кода истёк",
|
||||
"err_code_invalid_detail": "Покажите новый код в веб-почте (Настройки → Безопасность → Привязать мобильное приложение) и отсканируйте его.",
|
||||
"err_code_invalid_title": "Этот код недействителен",
|
||||
"err_code_used_detail": "Каждый код работает только один раз. Покажите новый в веб-почте и отсканируйте его снова.",
|
||||
"err_code_used_title": "Этот код уже использован",
|
||||
"err_generic_detail": "Что-то пошло не так. Попробуйте ещё раз.",
|
||||
"err_generic_title": "Не удалось войти",
|
||||
"err_interrupted_detail": "Ответ не соответствует начатому запросу. Попробуйте войти ещё раз.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Почтовый сервер не найден: {host}",
|
||||
"err_no_webmail_detail": "На этом сервере нет веб-почты Bulwark и входа через OAuth. Используйте пароль (или пароль приложения).",
|
||||
"err_no_webmail_title": "Нет страницы входа: {host}",
|
||||
"err_pair_connect_detail": "Покажите новый код на компьютере и отсканируйте его снова.",
|
||||
"err_pair_connect_title": "Вход выполнен, но не удалось подключиться к {host}",
|
||||
"err_pair_insecure_detail": "{host} хочет использовать незашифрованное соединение.",
|
||||
"err_pair_network_detail": "Проверьте подключение и доступна ли веб-почта с телефона, затем повторите попытку.",
|
||||
"err_pair_rate_detail": "Подождите минуту и повторите попытку.",
|
||||
"err_pair_server_detail": "Попробуйте ещё раз с новым кодом. Если ошибка повторяется, обратитесь к администратору.",
|
||||
"err_pair_server_title": "Веб-почте не удалось завершить вход",
|
||||
"err_pair_unsupported_detail": "{host} не поддерживает коды входа. Возможно, нужно обновление, или ссылка ведёт на неверный адрес.",
|
||||
"err_pair_unsupported_title": "Эта веб-почта не может привязывать устройства",
|
||||
"err_pair_untrusted_detail": "{host} отправил данные для входа на сервер, который приложение не может проверить.",
|
||||
"err_pair_untrusted_title": "Этому входу нельзя доверять",
|
||||
"err_rate_detail": "Сервер попросил немного подождать. Повторите попытку чуть позже.",
|
||||
"err_rate_detail_seconds": "Сервер попросил подождать. Повторите попытку примерно через {seconds} с.",
|
||||
"err_rate_title": "Слишком много попыток",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Я знаю адрес своего сервера",
|
||||
"known_server_desc": "Этот сервер уже известен, настройка не нужна",
|
||||
"known_server_title": "Ещё один аккаунт на {host}",
|
||||
"link_confirm_connect": "Ссылка предлагает войти на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.",
|
||||
"link_confirm_connect_add": "Ссылка предлагает добавить аккаунт, войдя на этой странице. Продолжайте, только если вы пользуетесь почтой на {host}.",
|
||||
"link_confirm_connect_title": "Открыть страницу входа {host}?",
|
||||
"link_confirm_pair": "Эта ссылка выполняет вход в аккаунт на {host}. Продолжайте, только если вы только что показали код входа на своём компьютере.",
|
||||
"link_confirm_pair_add": "Эта ссылка добавляет аккаунт с {host} и переключает на него приложение. Продолжайте, только если вы только что показали код входа на своём компьютере.",
|
||||
"link_confirm_title": "Войти по ссылке?",
|
||||
"looking_up": "Ищем ваш сервер…",
|
||||
"manual": "Ввести данные сервера вручную",
|
||||
"notice_bad_qr": "Это не код входа Bulwark",
|
||||
@@ -505,6 +529,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": "В разделе Настройки → Безопасность → Привязать мобильное приложение появится код. Наведите на него камеру.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "Тот же адрес, что вы вводите в браузере. Мы сами добавим https://.",
|
||||
"server_label": "Адрес сервера",
|
||||
"server_not_found": "Не удалось найти сервер для {domain}. Если у вас свой сервер, укажите адрес, по которому вы открываете веб-почту.",
|
||||
"server_paste_hint": "У вас есть ссылка для входа? Вставьте её сюда.",
|
||||
"server_scan_hint": "Если не уверены, отсканируйте код входа — в нём есть адрес сервера.",
|
||||
"server_subtitle": "Введите адрес, по которому вы открываете веб-почту.",
|
||||
"server_title": "Какой адрес у вашего сервера?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Aká je vaša e-mailová adresa?",
|
||||
"email_step_title_add": "Aká je tá druhá adresa?",
|
||||
"email_title": "Použiť moju e-mailovú adresu",
|
||||
"err_account_limit_detail": "Ak chcete pridať ďalší účet, odstráňte niektorý v Nastaveniach.",
|
||||
"err_bad_detail": "Skontrolujte e-mail a heslo. Ak váš účet používa dvojfaktorové prihlásenie, vytvorte vo webmaile heslo aplikácie a použite ho tu.",
|
||||
"err_bad_title": "To nevyšlo",
|
||||
"err_cert_detail": "Bezpečnostný certifikát servera bol odmietnutý. Ak ide o váš vlastný server, skontrolujte, či je certifikát platný a nevypršal.",
|
||||
"err_cert_title": "{host} sa nepodarilo overiť",
|
||||
"err_code_expired_detail": "Prihlasovacie kódy platia niekoľko minút. Vygenerujte vo webmaile nový a naskenujte ho znova.",
|
||||
"err_code_expired_detail": "Prihlasovacie kódy platia dve minúty. Zobrazte vo webmaile nový a naskenujte ho znova.",
|
||||
"err_code_expired_or_used_detail": "Zobrazte vo webmaile nový a naskenujte ho znova.",
|
||||
"err_code_expired_or_used_title": "Platnosť kódu vypršala alebo už bol použitý",
|
||||
"err_code_expired_title": "Platnosť kódu vypršala",
|
||||
"err_code_invalid_detail": "Zobrazte vo webmaile nový kód (Nastavenia → Zabezpečenie → Prepojiť mobilnú aplikáciu) a naskenujte ho.",
|
||||
"err_code_invalid_title": "Tento kód nie je platný",
|
||||
"err_code_used_detail": "Každý kód funguje len raz. Zobrazte vo webmaile nový a naskenujte ho znova.",
|
||||
"err_code_used_title": "Tento kód už bol použitý",
|
||||
"err_generic_detail": "Niečo sa pokazilo. Skúste to znova.",
|
||||
"err_generic_title": "Prihlásenie zlyhalo",
|
||||
"err_interrupted_detail": "Odpoveď nezodpovedala začatej požiadavke. Skúste sa prihlásiť znova.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Poštový server sa nenašiel: {host}",
|
||||
"err_no_webmail_detail": "Tento server nemá webmail Bulwark ani prihlásenie cez OAuth. Použite heslo (alebo heslo aplikácie).",
|
||||
"err_no_webmail_title": "Prihlasovacia stránka sa nenašla: {host}",
|
||||
"err_pair_connect_detail": "Zobrazte na počítači nový kód a naskenujte ho znova.",
|
||||
"err_pair_connect_title": "Prihlásené, ale nedá sa pripojiť k {host}",
|
||||
"err_pair_insecure_detail": "{host} chce použiť nešifrované pripojenie.",
|
||||
"err_pair_network_detail": "Skontrolujte pripojenie a či sa telefón dostane k webmailu, a skúste to znova.",
|
||||
"err_pair_rate_detail": "Počkajte minútu a skúste to znova.",
|
||||
"err_pair_server_detail": "Skúste to znova s novým kódom. Ak to stále zlyháva, obráťte sa na správcu.",
|
||||
"err_pair_server_title": "Webmail nedokázal dokončiť prihlásenie",
|
||||
"err_pair_unsupported_detail": "{host} nepodporuje prihlasovacie kódy. Možno potrebuje aktualizáciu, alebo odkaz vedie na nesprávnu adresu.",
|
||||
"err_pair_unsupported_title": "Tento webmail nedokáže prepájať zariadenia",
|
||||
"err_pair_untrusted_detail": "{host} poslal prihlasovacie údaje pre server, ktorý aplikácia nedokáže overiť.",
|
||||
"err_pair_untrusted_title": "Tomuto prihláseniu nemožno dôverovať",
|
||||
"err_rate_detail": "Server nás požiadal o chvíľu strpenia. Skúste to o chvíľu znova.",
|
||||
"err_rate_detail_seconds": "Server nás požiadal o strpenie. Skúste to znova približne o {seconds} s.",
|
||||
"err_rate_title": "Príliš veľa pokusov",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Poznám adresu svojho servera",
|
||||
"known_server_desc": "Tento server už poznáme, takže nie je potrebné nič nastavovať",
|
||||
"known_server_title": "Ďalší účet na {host}",
|
||||
"link_confirm_connect": "Odkaz žiada o prihlásenie na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.",
|
||||
"link_confirm_connect_add": "Odkaz žiada o pridanie účtu prihlásením na tejto stránke. Pokračujte, iba ak {host} používate na svoj e-mail.",
|
||||
"link_confirm_connect_title": "Otvoriť prihlasovaciu stránku {host}?",
|
||||
"link_confirm_pair": "Tento odkaz vás prihlási do účtu z {host}. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.",
|
||||
"link_confirm_pair_add": "Tento odkaz pridá účet z {host} a prepne naň aplikáciu. Pokračujte, iba ak ste práve na počítači zobrazili prihlasovací kód.",
|
||||
"link_confirm_title": "Prihlásiť sa pomocou odkazu?",
|
||||
"looking_up": "Hľadá sa váš server…",
|
||||
"manual": "Zadať údaje servera ručne",
|
||||
"notice_bad_qr": "Toto nie je prihlasovací kód Bulwark",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "E-posta adresiniz nedir?",
|
||||
"email_step_title_add": "Diğer adres nedir?",
|
||||
"email_title": "E-posta adresimi kullan",
|
||||
"err_account_limit_detail": "Başka bir hesap eklemek için Ayarlar'dan bir hesabı kaldırın.",
|
||||
"err_bad_detail": "E-posta adresinizi ve parolanızı kontrol edin. Hesabınız iki aşamalı giriş kullanıyorsa webmail'de bir uygulama parolası oluşturun ve burada onu kullanın.",
|
||||
"err_bad_title": "Bu işe yaramadı",
|
||||
"err_cert_detail": "Sunucunun güvenlik sertifikası reddedildi. Bu sizin sunucunuzsa sertifikanın geçerli olduğunu ve süresinin dolmadığını kontrol edin.",
|
||||
"err_cert_title": "{host} doğrulanamadı",
|
||||
"err_code_expired_detail": "Giriş kodları birkaç dakika geçerlidir. Webmail'de yeni bir kod oluşturup tekrar tarayın.",
|
||||
"err_code_expired_detail": "Giriş kodları iki dakika geçerlidir. Webmail'de yeni bir kod gösterip tekrar tarayın.",
|
||||
"err_code_expired_or_used_detail": "Webmail'de yeni bir kod gösterip tekrar tarayın.",
|
||||
"err_code_expired_or_used_title": "Bu kodun süresi doldu veya kod zaten kullanıldı",
|
||||
"err_code_expired_title": "Bu kodun süresi doldu",
|
||||
"err_code_invalid_detail": "Webmail'de yeni bir kod gösterin (Ayarlar → Güvenlik → Mobil uygulamayı bağla) ve tarayın.",
|
||||
"err_code_invalid_title": "Bu kod geçerli değil",
|
||||
"err_code_used_detail": "Her kod yalnızca bir kez çalışır. Webmail'de yeni bir kod gösterip tekrar tarayın.",
|
||||
"err_code_used_title": "Bu kod zaten kullanıldı",
|
||||
"err_generic_detail": "Bir şeyler ters gitti. Tekrar deneyin.",
|
||||
"err_generic_title": "Giriş başarısız",
|
||||
"err_interrupted_detail": "Yanıt, başlattığımız istekle eşleşmedi. Tekrar giriş yapmayı deneyin.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Posta sunucusu bulunamadı: {host}",
|
||||
"err_no_webmail_detail": "Bu sunucuda Bulwark webmail ve OAuth girişi yok. Bunun yerine parola (veya uygulama parolası) kullanın.",
|
||||
"err_no_webmail_title": "Giriş sayfası bulunamadı: {host}",
|
||||
"err_pair_connect_detail": "Bilgisayarınızda yeni bir kod gösterip tekrar tarayın.",
|
||||
"err_pair_connect_title": "Giriş yapıldı ancak {host} ile bağlantı kurulamadı",
|
||||
"err_pair_insecure_detail": "{host} şifrelenmemiş bir bağlantı kullanmak istiyor.",
|
||||
"err_pair_network_detail": "Bağlantınızı ve telefonunuzun webmail'e erişip erişemediğini kontrol edin, ardından tekrar deneyin.",
|
||||
"err_pair_rate_detail": "Bir dakika bekleyip tekrar deneyin.",
|
||||
"err_pair_server_detail": "Yeni bir kodla tekrar deneyin. Sorun devam ederse yöneticinize başvurun.",
|
||||
"err_pair_server_title": "Webmail girişi tamamlayamadı",
|
||||
"err_pair_unsupported_detail": "{host} giriş kodlarını desteklemiyor. Güncellenmesi gerekiyor olabilir ya da bağlantı yanlış adrese işaret ediyor.",
|
||||
"err_pair_unsupported_title": "Bu webmail cihaz bağlayamıyor",
|
||||
"err_pair_untrusted_detail": "{host}, uygulamanın doğrulayamadığı bir sunucu için giriş bilgileri gönderdi.",
|
||||
"err_pair_untrusted_title": "Bu girişe güvenilemez",
|
||||
"err_rate_detail": "Sunucu biraz beklememizi istedi. Kısa süre sonra tekrar deneyin.",
|
||||
"err_rate_detail_seconds": "Sunucu beklememizi istedi. Yaklaşık {seconds} saniye sonra tekrar deneyin.",
|
||||
"err_rate_title": "Çok fazla deneme",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Sunucu adresimi biliyorum",
|
||||
"known_server_desc": "Bu sunucuyu zaten tanıyoruz, kurulum gerekmiyor",
|
||||
"known_server_title": "{host} üzerinde başka bir hesap",
|
||||
"link_confirm_connect": "Bir bağlantı bu sayfada giriş yapmanızı istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.",
|
||||
"link_confirm_connect_add": "Bir bağlantı bu sayfada giriş yaparak hesap eklemenizi istiyor. Yalnızca e-postanız için {host} kullanıyorsanız devam edin.",
|
||||
"link_confirm_connect_title": "{host} giriş sayfası açılsın mı?",
|
||||
"link_confirm_pair": "Bu bağlantı {host} üzerindeki bir hesapta oturum açar. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.",
|
||||
"link_confirm_pair_add": "Bu bağlantı {host} üzerindeki bir hesabı ekler ve uygulamayı bu hesaba geçirir. Yalnızca bilgisayarınızda az önce bir giriş kodu gösterdiyseniz devam edin.",
|
||||
"link_confirm_title": "Bağlantıyla giriş yapılsın mı?",
|
||||
"looking_up": "Sunucunuz aranıyor…",
|
||||
"manual": "Sunucu bilgilerini elle girin",
|
||||
"notice_bad_qr": "Bu bir Bulwark giriş kodu değil",
|
||||
@@ -505,6 +529,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.",
|
||||
@@ -515,6 +545,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?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "Яка ваша адреса електронної пошти?",
|
||||
"email_step_title_add": "Яка ваша інша адреса?",
|
||||
"email_title": "Використати мою адресу електронної пошти",
|
||||
"err_account_limit_detail": "Видаліть обліковий запис у налаштуваннях, щоб додати новий.",
|
||||
"err_bad_detail": "Перевірте адресу електронної пошти та пароль. Якщо у вашому обліковому записі ввімкнено двофакторну автентифікацію, створіть пароль програми у веб-пошті й використайте його тут.",
|
||||
"err_bad_title": "Не вдалося",
|
||||
"err_cert_detail": "Сертифікат безпеки сервера відхилено. Якщо це ваш власний сервер, перевірте, що сертифікат дійсний і не прострочений.",
|
||||
"err_cert_title": "Не вдалося перевірити {host}",
|
||||
"err_code_expired_detail": "Коди входу діють кілька хвилин. Створіть новий у веб-пошті та відскануйте його знову.",
|
||||
"err_code_expired_detail": "Коди входу діють дві хвилини. Покажіть новий у веб-пошті та відскануйте його знову.",
|
||||
"err_code_expired_or_used_detail": "Покажіть новий у веб-пошті та відскануйте його знову.",
|
||||
"err_code_expired_or_used_title": "Термін дії коду минув, або його вже використано",
|
||||
"err_code_expired_title": "Термін дії коду минув",
|
||||
"err_code_invalid_detail": "Покажіть новий код у веб-пошті (Налаштування → Безпека → Прив'язати мобільний застосунок) і відскануйте його.",
|
||||
"err_code_invalid_title": "Цей код недійсний",
|
||||
"err_code_used_detail": "Кожен код працює лише один раз. Покажіть новий у веб-пошті та відскануйте його знову.",
|
||||
"err_code_used_title": "Цей код уже використано",
|
||||
"err_generic_detail": "Щось пішло не так. Спробуйте ще раз.",
|
||||
"err_generic_title": "Не вдалося ввійти",
|
||||
"err_interrupted_detail": "Відповідь не збігається з розпочатим запитом. Спробуйте ввійти ще раз.",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "Поштовий сервер не знайдено: {host}",
|
||||
"err_no_webmail_detail": "На цьому сервері немає веб-пошти Bulwark і входу через OAuth. Використайте пароль (або пароль програми).",
|
||||
"err_no_webmail_title": "Немає сторінки входу: {host}",
|
||||
"err_pair_connect_detail": "Покажіть новий код на комп'ютері та відскануйте його знову.",
|
||||
"err_pair_connect_title": "Вхід виконано, але не вдалося під'єднатися до {host}",
|
||||
"err_pair_insecure_detail": "{host} хоче використати незашифроване з'єднання.",
|
||||
"err_pair_network_detail": "Перевірте підключення та чи доступна веб-пошта з телефона, потім спробуйте ще раз.",
|
||||
"err_pair_rate_detail": "Зачекайте хвилину, потім спробуйте ще раз.",
|
||||
"err_pair_server_detail": "Спробуйте ще раз із новим кодом. Якщо помилка повторюється, зверніться до адміністратора.",
|
||||
"err_pair_server_title": "Веб-пошті не вдалося завершити вхід",
|
||||
"err_pair_unsupported_detail": "{host} не підтримує коди входу. Можливо, потрібне оновлення, або посилання веде на неправильну адресу.",
|
||||
"err_pair_unsupported_title": "Ця веб-пошта не може прив'язувати пристрої",
|
||||
"err_pair_untrusted_detail": "{host} надіслав дані для входу на сервер, який застосунок не може перевірити.",
|
||||
"err_pair_untrusted_title": "Цьому входу не можна довіряти",
|
||||
"err_rate_detail": "Сервер попросив трохи зачекати. Спробуйте ще раз згодом.",
|
||||
"err_rate_detail_seconds": "Сервер попросив зачекати. Спробуйте ще раз приблизно через {seconds} с.",
|
||||
"err_rate_title": "Забагато спроб",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "Я знаю адресу свого сервера",
|
||||
"known_server_desc": "Цей сервер уже відомий, налаштування не потрібне",
|
||||
"known_server_title": "Ще один обліковий запис на {host}",
|
||||
"link_confirm_connect": "Посилання пропонує увійти на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.",
|
||||
"link_confirm_connect_add": "Посилання пропонує додати обліковий запис, увійшовши на цій сторінці. Продовжуйте, лише якщо ви користуєтеся поштою на {host}.",
|
||||
"link_confirm_connect_title": "Відкрити сторінку входу {host}?",
|
||||
"link_confirm_pair": "Це посилання виконує вхід в обліковий запис на {host}. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.",
|
||||
"link_confirm_pair_add": "Це посилання додає обліковий запис з {host} і перемикає на нього застосунок. Продовжуйте, лише якщо ви щойно показали код входу на своєму комп'ютері.",
|
||||
"link_confirm_title": "Увійти за посиланням?",
|
||||
"looking_up": "Шукаємо ваш сервер…",
|
||||
"manual": "Ввести дані сервера вручну",
|
||||
"notice_bad_qr": "Це не код входу Bulwark",
|
||||
@@ -505,6 +529,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": "У розділі Налаштування → Безпека → Прив'язати мобільний застосунок з'явиться код. Наведіть на нього камеру.",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "Та сама адреса, яку ви вводите в браузері. Ми самі додамо https://.",
|
||||
"server_label": "Адреса сервера",
|
||||
"server_not_found": "Не вдалося знайти сервер для {domain}. Якщо у вас власний сервер, вкажіть адресу, за якою ви відкриваєте веб-пошту.",
|
||||
"server_paste_hint": "Маєте посилання для входу? Вставте його сюди.",
|
||||
"server_scan_hint": "Якщо не впевнені, відскануйте код входу — він містить адресу сервера.",
|
||||
"server_subtitle": "Введіть адресу, за якою ви відкриваєте веб-пошту.",
|
||||
"server_title": "Яка адреса вашого сервера?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "您的電子郵件地址是什麼?",
|
||||
"email_step_title_add": "另一個電子郵件地址是什麼?",
|
||||
"email_title": "使用我的電子郵件地址",
|
||||
"err_account_limit_detail": "請先在設定中移除一個帳號,再新增其他帳號。",
|
||||
"err_bad_detail": "請檢查您的電子郵件地址與密碼。如果您的帳號使用雙因素驗證登入,請在網頁郵件中建立應用程式密碼,並在此使用。",
|
||||
"err_bad_title": "無法登入",
|
||||
"err_cert_detail": "伺服器的安全性憑證遭到拒絕。如果這是您自己的伺服器,請確認憑證有效且未過期。",
|
||||
"err_cert_title": "無法驗證 {host}",
|
||||
"err_code_expired_detail": "登入碼只在幾分鐘內有效。請在網頁郵件中產生新的登入碼,再重新掃描。",
|
||||
"err_code_expired_detail": "登入碼的有效期限為兩分鐘。請在網頁郵件中顯示新的登入碼,再重新掃描。",
|
||||
"err_code_expired_or_used_detail": "請在網頁郵件中顯示新的登入碼,再重新掃描。",
|
||||
"err_code_expired_or_used_title": "此登入碼已過期或已被使用",
|
||||
"err_code_expired_title": "此登入碼已過期",
|
||||
"err_code_invalid_detail": "請在網頁郵件中顯示新的登入碼(「設定 → 安全 → 連結行動應用程式」),再進行掃描。",
|
||||
"err_code_invalid_title": "此登入碼無效",
|
||||
"err_code_used_detail": "每個登入碼只能使用一次。請在網頁郵件中顯示新的登入碼,再重新掃描。",
|
||||
"err_code_used_title": "此登入碼已被使用",
|
||||
"err_generic_detail": "發生錯誤。請再試一次。",
|
||||
"err_generic_title": "登入失敗",
|
||||
"err_interrupted_detail": "回應與我們發出的請求不符。請重新登入。",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "{host} 上沒有郵件伺服器",
|
||||
"err_no_webmail_detail": "此伺服器沒有 Bulwark 網頁郵件,也不支援 OAuth 登入。請改用密碼(或應用程式密碼)。",
|
||||
"err_no_webmail_title": "{host} 上沒有登入頁面",
|
||||
"err_pair_connect_detail": "請在電腦上顯示新的登入碼,再重新掃描。",
|
||||
"err_pair_connect_title": "已登入,但無法連線至 {host}",
|
||||
"err_pair_insecure_detail": "{host} 想要使用未加密的連線。",
|
||||
"err_pair_network_detail": "請檢查網路連線,並確認手機可以連上網頁郵件,然後再試一次。",
|
||||
"err_pair_rate_detail": "請等候一分鐘後再試一次。",
|
||||
"err_pair_server_detail": "請使用新的登入碼再試一次。如果仍然失敗,請洽詢管理員。",
|
||||
"err_pair_server_title": "網頁郵件無法完成登入",
|
||||
"err_pair_unsupported_detail": "{host} 不支援登入碼。可能需要更新,或連結指向了錯誤的地址。",
|
||||
"err_pair_unsupported_title": "此網頁郵件無法連結裝置",
|
||||
"err_pair_untrusted_detail": "{host} 傳送了應用程式無法驗證的伺服器登入資訊。",
|
||||
"err_pair_untrusted_title": "無法信任此次登入",
|
||||
"err_rate_detail": "伺服器要求我們稍候。請稍後再試。",
|
||||
"err_rate_detail_seconds": "伺服器要求我們等待。請在約 {seconds} 秒後再試。",
|
||||
"err_rate_title": "嘗試次數過多",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "我知道伺服器地址",
|
||||
"known_server_desc": "我們已知道此伺服器,不需要設定",
|
||||
"known_server_title": "{host} 上的其他帳號",
|
||||
"link_confirm_connect": "有連結要求在此頁面登入。只有在您的郵件使用 {host} 時才繼續。",
|
||||
"link_confirm_connect_add": "有連結要求透過在此頁面登入來新增帳號。只有在您的郵件使用 {host} 時才繼續。",
|
||||
"link_confirm_connect_title": "要開啟 {host} 的登入頁面嗎?",
|
||||
"link_confirm_pair": "此連結會登入 {host} 上的帳號。只有在您剛在電腦上顯示登入碼時才繼續。",
|
||||
"link_confirm_pair_add": "此連結會新增 {host} 上的帳號,並將應用程式切換到該帳號。只有在您剛在電腦上顯示登入碼時才繼續。",
|
||||
"link_confirm_title": "要使用連結登入嗎?",
|
||||
"looking_up": "正在尋找您的伺服器…",
|
||||
"manual": "手動輸入伺服器資訊",
|
||||
"notice_bad_qr": "此代碼不是 Bulwark 登入碼",
|
||||
@@ -505,6 +529,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": "「設定 → 安全 → 連結行動應用程式」會顯示代碼。請將相機對準代碼。",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "與您在瀏覽器中輸入的地址相同。我們會自動加上 https://。",
|
||||
"server_label": "伺服器地址",
|
||||
"server_not_found": "找不到 {domain} 的伺服器。如果您自行架設,請使用開啟網頁郵件時的地址。",
|
||||
"server_paste_hint": "有登入連結嗎?請貼到這裡。",
|
||||
"server_scan_hint": "如果不確定,請改為掃描登入碼。登入碼中包含伺服器地址。",
|
||||
"server_subtitle": "請輸入您開啟網頁郵件時使用的地址。",
|
||||
"server_title": "您的伺服器地址是什麼?",
|
||||
|
||||
+32
-1
@@ -460,12 +460,19 @@
|
||||
"email_step_title": "您的邮箱地址是什么?",
|
||||
"email_step_title_add": "另一个邮箱地址是什么?",
|
||||
"email_title": "使用我的邮箱地址",
|
||||
"err_account_limit_detail": "请在设置中移除一个账户后再添加新账户。",
|
||||
"err_bad_detail": "请检查您的邮箱地址和密码。如果您的账户使用双因素身份验证登录,请在网页邮箱中创建应用密码并在此使用。",
|
||||
"err_bad_title": "无法登录",
|
||||
"err_cert_detail": "服务器的安全证书被拒绝。如果这是您自己的服务器,请检查证书是否有效且未过期。",
|
||||
"err_cert_title": "无法验证 {host}",
|
||||
"err_code_expired_detail": "登录码仅在几分钟内有效。请在网页邮箱中生成新的登录码,然后重新扫描。",
|
||||
"err_code_expired_detail": "登录码的有效期为两分钟。请在网页邮箱中显示新的登录码,然后重新扫描。",
|
||||
"err_code_expired_or_used_detail": "请在网页邮箱中显示新的登录码,然后重新扫描。",
|
||||
"err_code_expired_or_used_title": "该登录码已过期或已被使用",
|
||||
"err_code_expired_title": "该登录码已过期",
|
||||
"err_code_invalid_detail": "请在网页邮箱中显示新的登录码(“设置 → 安全 → 关联移动应用”),然后扫描。",
|
||||
"err_code_invalid_title": "该登录码无效",
|
||||
"err_code_used_detail": "每个登录码只能使用一次。请在网页邮箱中显示新的登录码,然后重新扫描。",
|
||||
"err_code_used_title": "该登录码已被使用",
|
||||
"err_generic_detail": "出现问题。请重试。",
|
||||
"err_generic_title": "登录失败",
|
||||
"err_interrupted_detail": "响应与我们发起的请求不匹配。请重新登录。",
|
||||
@@ -474,6 +481,17 @@
|
||||
"err_no_server_title": "{host} 上没有邮件服务器",
|
||||
"err_no_webmail_detail": "此服务器没有 Bulwark 网页邮箱,也不支持 OAuth 登录。请改用密码(或应用密码)。",
|
||||
"err_no_webmail_title": "{host} 上没有登录页面",
|
||||
"err_pair_connect_detail": "请在电脑上显示新的登录码,然后重新扫描。",
|
||||
"err_pair_connect_title": "已登录,但无法连接到 {host}",
|
||||
"err_pair_insecure_detail": "{host} 想要使用未加密的连接。",
|
||||
"err_pair_network_detail": "请检查网络连接,并确认手机可以访问网页邮箱,然后重试。",
|
||||
"err_pair_rate_detail": "请等待一分钟后重试。",
|
||||
"err_pair_server_detail": "请使用新的登录码重试。如果仍然失败,请联系管理员。",
|
||||
"err_pair_server_title": "网页邮箱无法完成登录",
|
||||
"err_pair_unsupported_detail": "{host} 不支持登录码。它可能需要更新,或者链接指向了错误的地址。",
|
||||
"err_pair_unsupported_title": "该网页邮箱无法关联设备",
|
||||
"err_pair_untrusted_detail": "{host} 发送了应用无法验证的服务器的登录信息。",
|
||||
"err_pair_untrusted_title": "无法信任此次登录",
|
||||
"err_rate_detail": "服务器要求我们稍候。请稍后重试。",
|
||||
"err_rate_detail_seconds": "服务器要求我们等待。请在约 {seconds} 秒后重试。",
|
||||
"err_rate_title": "尝试次数过多",
|
||||
@@ -487,6 +505,12 @@
|
||||
"know_server": "我知道服务器地址",
|
||||
"known_server_desc": "我们已经知道此服务器,无需设置",
|
||||
"known_server_title": "{host} 上的其他账户",
|
||||
"link_confirm_connect": "有链接请求在此页面登录。仅当您的邮箱使用 {host} 时才继续。",
|
||||
"link_confirm_connect_add": "有链接请求通过在此页面登录来添加账户。仅当您的邮箱使用 {host} 时才继续。",
|
||||
"link_confirm_connect_title": "打开 {host} 的登录页面?",
|
||||
"link_confirm_pair": "此链接将登录 {host} 上的账户。仅当您刚刚在电脑上显示了登录码时才继续。",
|
||||
"link_confirm_pair_add": "此链接将添加 {host} 上的账户,并将应用切换到该账户。仅当您刚刚在电脑上显示了登录码时才继续。",
|
||||
"link_confirm_title": "使用链接登录?",
|
||||
"looking_up": "正在查找您的服务器…",
|
||||
"manual": "手动输入服务器信息",
|
||||
"notice_bad_qr": "该代码不是 Bulwark 登录码",
|
||||
@@ -505,6 +529,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": "“设置 → 安全 → 关联移动应用”中会显示一个二维码。请将相机对准它。",
|
||||
@@ -515,6 +545,7 @@
|
||||
"server_hint": "即您在浏览器中输入的地址。我们会自动为您添加 https://。",
|
||||
"server_label": "服务器地址",
|
||||
"server_not_found": "找不到 {domain} 的服务器。如果您自行托管,请使用访问网页邮箱时的地址。",
|
||||
"server_paste_hint": "有登录链接?请粘贴到这里。",
|
||||
"server_scan_hint": "如果不确定,请改为扫描登录码。登录码中包含服务器地址。",
|
||||
"server_subtitle": "请输入您访问网页邮箱时使用的地址。",
|
||||
"server_title": "您的服务器地址是什么?",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.64",
|
||||
"version": "0.1.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.64",
|
||||
"version": "0.1.67",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.64",
|
||||
"version": "0.1.67",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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 { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
||||
import { describeLoginError } from '../lib/login-errors';
|
||||
|
||||
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) {
|
||||
// A pairing link for a plain-http webmail: say why it won't be used.
|
||||
const insecure = insecurePairingLinkError(text);
|
||||
if (insecure) {
|
||||
const copy = describeLoginError(insecure, { t });
|
||||
setError(copy.detail ?? copy.title);
|
||||
return;
|
||||
}
|
||||
setError(t('login.mobile.paste_invalid', "That isn't a Bulwark sign-in link"));
|
||||
return;
|
||||
}
|
||||
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,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describeLoginError } from '../login-errors';
|
||||
import { PairingError, insecurePairingLinkError, type PairingErrorReason } from '../oauth';
|
||||
import { AccountLimitError } from '../account-utils';
|
||||
|
||||
function named(name: string, message: string): Error {
|
||||
const err = new Error(message);
|
||||
@@ -19,6 +21,24 @@ describe('describeLoginError', () => {
|
||||
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
|
||||
});
|
||||
|
||||
it('says the account limit is reached and what to do about it', () => {
|
||||
const want = {
|
||||
title: 'Maximum of 10 accounts reached.',
|
||||
detail: 'Remove an account in Settings to add another one.',
|
||||
};
|
||||
expect(describeLoginError(new AccountLimitError())).toEqual(want);
|
||||
// As the store keeps it: the message alone.
|
||||
expect(describeLoginError('Maximum of 10 accounts reached')).toEqual(want);
|
||||
});
|
||||
|
||||
it('explains a scanned, pasted or tapped pairing link for a plain-http webmail', () => {
|
||||
const link = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org/mail')}&code=${'a1'.repeat(32)}`;
|
||||
expect(describeLoginError(insecurePairingLinkError(link))).toEqual({
|
||||
title: "This sign-in can't be trusted",
|
||||
detail: 'webmail.example.org wants to use an unencrypted connection.',
|
||||
});
|
||||
});
|
||||
|
||||
it('names the host it could not reach', () => {
|
||||
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
|
||||
serverUrl: 'https://mail.example.com',
|
||||
@@ -45,10 +65,10 @@ describe('describeLoginError', () => {
|
||||
expect(copy.title).toBe("Couldn't verify mail.example.com");
|
||||
});
|
||||
|
||||
it('explains an expired pairing code', () => {
|
||||
it('explains a pairing failure the store kept only as a message', () => {
|
||||
const copy = describeLoginError(new Error('Pairing code is invalid or has expired'));
|
||||
expect(copy.title).toBe('That code has expired');
|
||||
expect(copy.detail).toMatch(/fresh one/i);
|
||||
expect(copy.title).toBe('That code has expired or was already used');
|
||||
expect(copy.detail).toBe('Show a new one in the webmail and scan again.');
|
||||
});
|
||||
|
||||
it('passes through an unrecognised message rather than inventing a cause', () => {
|
||||
@@ -62,3 +82,76 @@ describe('describeLoginError', () => {
|
||||
expect(describeLoginError('boom').detail).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeLoginError for sign-in codes', () => {
|
||||
const webmail = { serverUrl: 'https://webmail.example.org/mail' };
|
||||
const pairing = (reason: PairingErrorReason, host?: string) =>
|
||||
describeLoginError(new PairingError(reason, 'raw message', host ? { host } : undefined), webmail);
|
||||
|
||||
it('says what happened to the code', () => {
|
||||
expect(pairing('expired')).toEqual({
|
||||
title: 'That code has expired',
|
||||
detail: 'Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.',
|
||||
});
|
||||
expect(pairing('used')).toEqual({
|
||||
title: 'That code was already used',
|
||||
detail: 'Each code works once. Show a new one in the webmail and scan again.',
|
||||
});
|
||||
expect(pairing('expired_or_used')).toEqual({
|
||||
title: 'That code has expired or was already used',
|
||||
detail: 'Show a new one in the webmail and scan again.',
|
||||
});
|
||||
expect(pairing('invalid')).toEqual({
|
||||
title: "That code isn't valid",
|
||||
detail: 'Show a new code in the webmail (Settings → Security → Link Mobile App) and scan it.',
|
||||
});
|
||||
});
|
||||
|
||||
it('names the webmail for problems with the redeem request', () => {
|
||||
expect(pairing('unsupported', 'webmail.example.org')).toEqual({
|
||||
title: "This webmail can't link devices",
|
||||
detail: "webmail.example.org doesn't support sign-in codes. It may need an update, or the link points to the wrong address.",
|
||||
});
|
||||
expect(pairing('network', 'webmail.example.org')).toEqual({
|
||||
title: "Can't reach webmail.example.org",
|
||||
detail: 'Check your connection and that your phone can reach the webmail, then try again.',
|
||||
});
|
||||
// Without a host on the error, the screen's address is used.
|
||||
expect(pairing('network').title).toBe("Can't reach webmail.example.org");
|
||||
expect(pairing('untrusted', 'webmail.example.org')).toEqual({
|
||||
title: "This sign-in can't be trusted",
|
||||
detail: "webmail.example.org sent sign-in details for a server the app can't verify.",
|
||||
});
|
||||
expect(pairing('insecure', 'webmail.example.org')).toEqual({
|
||||
title: "This sign-in can't be trusted",
|
||||
detail: 'webmail.example.org wants to use an unencrypted connection.',
|
||||
});
|
||||
});
|
||||
|
||||
it('explains rate limiting and server trouble', () => {
|
||||
expect(pairing('rate_limited')).toEqual({ title: 'Too many attempts', detail: 'Wait a minute, then try again.' });
|
||||
const server = {
|
||||
title: "The webmail couldn't complete the sign-in",
|
||||
detail: 'Try again with a new code. If it keeps failing, ask your administrator.',
|
||||
};
|
||||
expect(pairing('server')).toEqual(server);
|
||||
expect(pairing('bad_response')).toEqual(server);
|
||||
});
|
||||
|
||||
it('names the mail server when the code worked but connecting did not', () => {
|
||||
expect(pairing('connect_failed', 'mail.example.com')).toEqual({
|
||||
title: "Signed in, but couldn't connect to mail.example.com",
|
||||
detail: 'Show a new code on your computer and scan again.',
|
||||
});
|
||||
});
|
||||
|
||||
it('translates through t with the host filled in', () => {
|
||||
const t = (key: string, _fallback?: string, params?: Record<string, string | number>) =>
|
||||
`${key}${params?.host ? `(${params.host})` : ''}`;
|
||||
expect(describeLoginError(new PairingError('connect_failed', 'x', { host: 'mail.example.com' }), { t })).toEqual({
|
||||
title: 'login.mobile.err_pair_connect_title(mail.example.com)',
|
||||
detail: 'login.mobile.err_pair_connect_detail',
|
||||
});
|
||||
expect(describeLoginError(new PairingError('expired'), { t }).title).toBe('login.mobile.err_code_expired_title');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockSecureFetch } = vi.hoisted(() => ({
|
||||
mockSecureFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../client-cert', () => ({
|
||||
secureFetch: mockSecureFetch,
|
||||
}));
|
||||
|
||||
import {
|
||||
PAIRING_REDEEM_TIMEOUT_MS,
|
||||
PairingError,
|
||||
TransientRefreshError,
|
||||
insecurePairingLinkError,
|
||||
isInsecurePairingUrl,
|
||||
parsePastedSignInLink,
|
||||
parseQrLoginPayload,
|
||||
redeemPairingCode,
|
||||
refreshOAuthAccessToken,
|
||||
signInLinkHost,
|
||||
} from '../oauth';
|
||||
|
||||
const CODE = 'a1'.repeat(32);
|
||||
const WEBMAIL = 'https://webmail.example.org/mail';
|
||||
const SERVER = 'https://mail.example.com';
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
function html(status: number): Response {
|
||||
return new Response('<!doctype html><html><body>Not here</body></html>', {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
|
||||
async function redeemFailure(response: Response | Error): Promise<PairingError> {
|
||||
if (response instanceof Error) mockSecureFetch.mockRejectedValueOnce(response);
|
||||
else mockSecureFetch.mockResolvedValueOnce(response);
|
||||
try {
|
||||
await redeemPairingCode(WEBMAIL, CODE);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PairingError);
|
||||
return err as PairingError;
|
||||
}
|
||||
throw new Error('redeemPairingCode resolved');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('parseQrLoginPayload', () => {
|
||||
const server = encodeURIComponent('https://mail.example.com/webmail');
|
||||
|
||||
it('reads a pairing link, with or without a slash after the host', () => {
|
||||
const expected = { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code: CODE };
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${server}&code=${CODE}`)).toEqual(expected);
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair/?server=${server}&code=${CODE}`)).toEqual(expected);
|
||||
expect(parseQrLoginPayload(` BulwarkMail://PAIR?code=${CODE}&server=${server}\n`)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('reads a server-bootstrap link and a bare webmail address', () => {
|
||||
expect(parseQrLoginPayload('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com'))
|
||||
.toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
|
||||
expect(parseQrLoginPayload('bulwarkmail://connect/?server=https://mail.example.com'))
|
||||
.toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
|
||||
// Unchanged: a connect link may still name a plain-http server.
|
||||
expect(parseQrLoginPayload('bulwarkmail://connect?server=http://mail.example.com'))
|
||||
.toEqual({ kind: 'connect', webmailUrl: 'http://mail.example.com' });
|
||||
expect(parseQrLoginPayload('https://mail.example.com')).toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
|
||||
});
|
||||
|
||||
it('only redeems a pairing code over https, or plain http on loopback', () => {
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=http://mail.example.com&code=${CODE}`)).toBeNull();
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=http%3A%2F%2F192.168.1.5%3A3000&code=${CODE}`)).toBeNull();
|
||||
for (const dev of ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://10.0.2.2:3000']) {
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${encodeURIComponent(dev)}&code=${CODE}`))
|
||||
.toEqual({ kind: 'pair', webmailUrl: dev, code: CODE });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects links without a server or a usable code, and garbage', () => {
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?code=${CODE}`)).toBeNull();
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${server}`)).toBeNull();
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=${server}&code=has%20space`)).toBeNull();
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pair?server=javascript:alert(1)&code=${CODE}`)).toBeNull();
|
||||
expect(parseQrLoginPayload(`bulwarkmail://unlock?server=${server}&code=${CODE}`)).toBeNull();
|
||||
expect(parseQrLoginPayload(`bulwarkmail://pairing?server=${server}&code=${CODE}`)).toBeNull();
|
||||
expect(parseQrLoginPayload('WIFI:S:home;T:WPA;P:secret;;')).toBeNull();
|
||||
expect(parseQrLoginPayload('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePastedSignInLink', () => {
|
||||
it('finds the link inside pasted text', () => {
|
||||
const link = `bulwarkmail://pair?server=${encodeURIComponent(WEBMAIL)}&code=${CODE}`;
|
||||
expect(parsePastedSignInLink(`Open this on your phone: ${link}.`))
|
||||
.toEqual({ kind: 'pair', webmailUrl: WEBMAIL, code: CODE });
|
||||
expect(parsePastedSignInLink(`\n ${link} \n`)).toEqual({ kind: 'pair', webmailUrl: WEBMAIL, code: CODE });
|
||||
});
|
||||
|
||||
it('accepts a pasted webmail address and rejects anything else', () => {
|
||||
expect(parsePastedSignInLink('https://mail.example.com')).toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' });
|
||||
expect(parsePastedSignInLink('my code is 123456')).toBeNull();
|
||||
expect(parsePastedSignInLink('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insecurePairingLinkError', () => {
|
||||
const httpLink = `bulwarkmail://pair?server=${encodeURIComponent('http://Mail.Example.com:8080/webmail')}&code=${CODE}`;
|
||||
|
||||
it('says why a pairing link for a plain-http webmail is refused', () => {
|
||||
// Still refused by the parser: nothing can redeem it.
|
||||
expect(parseQrLoginPayload(httpLink)).toBeNull();
|
||||
expect(parsePastedSignInLink(`Open ${httpLink} on your phone.`)).toBeNull();
|
||||
|
||||
for (const text of [httpLink, `Open ${httpLink}.`, `bulwarkmail://pair/?code=${CODE}&server=http://mail.example.com:8080`]) {
|
||||
const err = insecurePairingLinkError(text);
|
||||
expect(err).toBeInstanceOf(PairingError);
|
||||
expect(err).toMatchObject({ reason: 'insecure', host: 'mail.example.com:8080' });
|
||||
}
|
||||
});
|
||||
|
||||
it('names the host the code would really go to', () => {
|
||||
const spoof = `bulwarkmail://pair?server=${encodeURIComponent('http://webmail.example.org@attacker.example')}&code=${CODE}`;
|
||||
expect(insecurePairingLinkError(spoof)).toMatchObject({ host: 'attacker.example' });
|
||||
});
|
||||
|
||||
it('is null for links that are fine or broken for another reason', () => {
|
||||
expect(insecurePairingLinkError(`bulwarkmail://pair?server=${encodeURIComponent(WEBMAIL)}&code=${CODE}`)).toBeNull();
|
||||
expect(insecurePairingLinkError(`bulwarkmail://pair?server=http%3A%2F%2Flocalhost%3A3000&code=${CODE}`)).toBeNull();
|
||||
expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com&code=has%20space')).toBeNull();
|
||||
expect(insecurePairingLinkError('bulwarkmail://pair?server=http://mail.example.com')).toBeNull();
|
||||
expect(insecurePairingLinkError('bulwarkmail://connect?server=http://mail.example.com')).toBeNull();
|
||||
expect(insecurePairingLinkError(`bulwarkmail://pair?server=ftp://mail.example.com&code=${CODE}`)).toBeNull();
|
||||
expect(insecurePairingLinkError('http://mail.example.com')).toBeNull();
|
||||
expect(insecurePairingLinkError('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsecurePairingUrl', () => {
|
||||
it('allows https and loopback http only', () => {
|
||||
expect(isInsecurePairingUrl('https://mail.example.com')).toBe(false);
|
||||
expect(isInsecurePairingUrl('http://localhost:3000')).toBe(false);
|
||||
expect(isInsecurePairingUrl('http://10.0.2.2:3000')).toBe(false);
|
||||
expect(isInsecurePairingUrl('http://mail.example.com')).toBe(true);
|
||||
expect(isInsecurePairingUrl('http://192.168.1.5:3000')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInLinkHost', () => {
|
||||
it('names the host the link talks to', () => {
|
||||
expect(signInLinkHost('https://mail.example.com/webmail')).toBe('mail.example.com');
|
||||
expect(signInLinkHost('https://Mail.Example.com:8443')).toBe('mail.example.com:8443');
|
||||
expect(signInLinkHost('http://127.0.0.1:3000/?x=1')).toBe('127.0.0.1:3000');
|
||||
});
|
||||
|
||||
it('is not fooled by user info or backslashes', () => {
|
||||
expect(signInLinkHost('https://mail.example.com@attacker.example/webmail')).toBe('attacker.example');
|
||||
expect(signInLinkHost('https://a@mail.example.com@attacker.example')).toBe('attacker.example');
|
||||
expect(signInLinkHost('https://attacker.example\\@mail.example.com')).toBe('attacker.example');
|
||||
expect(signInLinkHost('https://attacker.example#@mail.example.com')).toBe('attacker.example');
|
||||
expect(signInLinkHost('https://attacker.example?@mail.example.com')).toBe('attacker.example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redeemPairingCode', () => {
|
||||
it('posts the code to the webmail and returns an OAuth bundle for its token proxy', async () => {
|
||||
mockSecureFetch.mockResolvedValueOnce(json(200, {
|
||||
flow: 'oauth',
|
||||
server_url: SERVER,
|
||||
access_token: 'at-1',
|
||||
refresh_token: 'sealed.opaque.refresh',
|
||||
expires_in: 3600,
|
||||
token_endpoint: `${WEBMAIL}/api/auth/pair/token`,
|
||||
client_id: 'bulwark-webmail',
|
||||
}));
|
||||
const before = Date.now();
|
||||
|
||||
const result = await redeemPairingCode(`${WEBMAIL}/`, CODE);
|
||||
|
||||
expect(result).toEqual({
|
||||
flow: 'oauth',
|
||||
serverUrl: SERVER,
|
||||
tokens: {
|
||||
accessToken: 'at-1',
|
||||
refreshToken: 'sealed.opaque.refresh',
|
||||
expiresAt: expect.any(Number),
|
||||
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
|
||||
clientId: 'bulwark-webmail',
|
||||
source: 'pairing',
|
||||
},
|
||||
});
|
||||
if (result.flow !== 'oauth') throw new Error('unreachable');
|
||||
expect(result.tokens.expiresAt).toBeGreaterThanOrEqual(before + 3600_000);
|
||||
|
||||
const [url, init] = mockSecureFetch.mock.calls[0];
|
||||
expect(url).toBe(`${WEBMAIL}/api/auth/pair/redeem`);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({ pairing_code: CODE });
|
||||
expect(Object.keys(init.headers).map((h) => h.toLowerCase())).not.toContain('origin');
|
||||
expect(Object.keys(init.headers).map((h) => h.toLowerCase())).not.toContain('cookie');
|
||||
// Deadline for both the fetch path (signal) and the client-cert path.
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(init.timeoutMs).toBe(PAIRING_REDEEM_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it('accepts the mail server as token endpoint, and a bundle without `flow` from an older webmail', async () => {
|
||||
mockSecureFetch.mockResolvedValueOnce(json(200, {
|
||||
server_url: SERVER,
|
||||
access_token: 'at-2',
|
||||
token_endpoint: `${SERVER}/auth/token`,
|
||||
client_id: 'webmail',
|
||||
}));
|
||||
const result = await redeemPairingCode(WEBMAIL, CODE);
|
||||
expect(result).toMatchObject({ flow: 'oauth', serverUrl: SERVER, tokens: { tokenEndpoint: `${SERVER}/auth/token`, source: 'pairing' } });
|
||||
});
|
||||
|
||||
it('returns app-password credentials', async () => {
|
||||
mockSecureFetch.mockResolvedValueOnce(json(200, {
|
||||
flow: 'password',
|
||||
server_url: SERVER,
|
||||
username: 'ada@example.com',
|
||||
password: 'app-pass-123',
|
||||
}));
|
||||
await expect(redeemPairingCode(WEBMAIL, CODE)).resolves.toEqual({
|
||||
flow: 'password',
|
||||
serverUrl: SERVER,
|
||||
username: 'ada@example.com',
|
||||
password: 'app-pass-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a loopback http mail server for development', async () => {
|
||||
mockSecureFetch.mockResolvedValueOnce(json(200, {
|
||||
flow: 'password', server_url: 'http://10.0.2.2:8080', username: 'u', password: 'p',
|
||||
}));
|
||||
await expect(redeemPairingCode('http://10.0.2.2:3000', CODE)).resolves.toMatchObject({ serverUrl: 'http://10.0.2.2:8080' });
|
||||
});
|
||||
|
||||
it('never sends a code to a plain-http webmail', async () => {
|
||||
await expect(redeemPairingCode('http://webmail.example.org/mail', CODE))
|
||||
.rejects.toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'webmail.example.org' });
|
||||
expect(mockSecureFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells an unknown code from an expired or a used one', async () => {
|
||||
expect((await redeemFailure(json(400, { error: 'invalid_code' }))).reason).toBe('invalid');
|
||||
expect((await redeemFailure(json(410, { error: 'expired_code' }))).reason).toBe('expired');
|
||||
expect((await redeemFailure(json(410, { error: 'used_code' }))).reason).toBe('used');
|
||||
expect((await redeemFailure(json(410, {}))).reason).toBe('expired_or_used');
|
||||
});
|
||||
|
||||
it('reads the catch-all 400 of webmail up to 1.11 as expired or used', async () => {
|
||||
expect((await redeemFailure(json(400, { error: 'Invalid or expired pairing code' }))).reason).toBe('expired_or_used');
|
||||
expect((await redeemFailure(json(400, { error: 'Missing pairing code' }))).reason).toBe('expired_or_used');
|
||||
});
|
||||
|
||||
it('says the webmail cannot pair when the route is missing, naming the webmail', async () => {
|
||||
const err = await redeemFailure(html(404));
|
||||
expect(err.reason).toBe('unsupported');
|
||||
expect(err.host).toBe('webmail.example.org');
|
||||
});
|
||||
|
||||
it('maps rate limiting and server errors', async () => {
|
||||
expect((await redeemFailure(json(429, { error: 'Too many requests' }))).reason).toBe('rate_limited');
|
||||
expect((await redeemFailure(json(500, { error: 'Internal server error' }))).reason).toBe('server');
|
||||
expect((await redeemFailure(html(502))).reason).toBe('server');
|
||||
});
|
||||
|
||||
it('refuses a 200 that is not JSON', async () => {
|
||||
expect((await redeemFailure(html(200))).reason).toBe('bad_response');
|
||||
});
|
||||
|
||||
it('refuses answers with missing fields or an unknown flow', async () => {
|
||||
expect((await redeemFailure(json(200, { flow: 'oauth', server_url: SERVER, access_token: 'x' }))).reason).toBe('bad_response');
|
||||
expect((await redeemFailure(json(200, { flow: 'password', server_url: SERVER, username: 'u' }))).reason).toBe('bad_response');
|
||||
expect((await redeemFailure(json(200, { flow: 'magic', server_url: SERVER }))).reason).toBe('bad_response');
|
||||
expect((await redeemFailure(json(200, { flow: 'password', username: 'u', password: 'p' }))).reason).toBe('bad_response');
|
||||
});
|
||||
|
||||
it('reports an unreachable webmail as a network problem', async () => {
|
||||
const err = await redeemFailure(new TypeError('Network request failed'));
|
||||
expect(err.reason).toBe('network');
|
||||
expect(err.host).toBe('webmail.example.org');
|
||||
});
|
||||
|
||||
it('gives up after the timeout and aborts the request', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockSecureFetch.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
|
||||
const pending = redeemPairingCode(WEBMAIL, CODE);
|
||||
const assertion = expect(pending).rejects.toMatchObject({ name: 'PairingError', reason: 'network' });
|
||||
await vi.advanceTimersByTimeAsync(PAIRING_REDEEM_TIMEOUT_MS);
|
||||
await assertion;
|
||||
expect((mockSecureFetch.mock.calls[0][1].signal as AbortSignal).aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not hand a refresh token to a token endpoint on a foreign host', async () => {
|
||||
const err = await redeemFailure(json(200, {
|
||||
flow: 'oauth',
|
||||
server_url: SERVER,
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_endpoint: 'https://attacker.example.net/token',
|
||||
client_id: 'c',
|
||||
}));
|
||||
expect(err.reason).toBe('untrusted');
|
||||
});
|
||||
|
||||
it('refuses an unencrypted mail server', async () => {
|
||||
const err = await redeemFailure(json(200, {
|
||||
flow: 'password', server_url: 'http://mail.example.com', username: 'u', password: 'p',
|
||||
}));
|
||||
expect(err.reason).toBe('insecure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshOAuthAccessToken with a non-JSON answer', () => {
|
||||
it('keeps the account: a 200 page that is not JSON is transient', async () => {
|
||||
mockSecureFetch.mockResolvedValueOnce(html(200));
|
||||
await expect(refreshOAuthAccessToken({
|
||||
accessToken: 'old',
|
||||
refreshToken: 'non-json-refresh',
|
||||
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
|
||||
clientId: 'bulwark-webmail',
|
||||
source: 'pairing',
|
||||
})).rejects.toBeInstanceOf(TransientRefreshError);
|
||||
});
|
||||
|
||||
it('sends the standard form-encoded refresh grant to the token proxy', async () => {
|
||||
mockSecureFetch.mockResolvedValueOnce(json(200, { access_token: 'new', expires_in: 60 }));
|
||||
const next = await refreshOAuthAccessToken({
|
||||
accessToken: 'old',
|
||||
refreshToken: 'sealed.opaque.refresh',
|
||||
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
|
||||
clientId: 'bulwark-webmail',
|
||||
source: 'pairing',
|
||||
});
|
||||
expect(next).toMatchObject({ accessToken: 'new', refreshToken: 'sealed.opaque.refresh', source: 'pairing' });
|
||||
const [url, init] = mockSecureFetch.mock.calls[0];
|
||||
expect(url).toBe(`${WEBMAIL}/api/auth/pair/token`);
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'sealed.opaque.refresh',
|
||||
client_id: 'bulwark-webmail',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,16 @@
|
||||
// (MAX_SSE_STREAMS), so nothing scales with this number at runtime.
|
||||
export const MAX_ACCOUNTS = 10;
|
||||
|
||||
/** A new account would take the registry past MAX_ACCOUNTS. */
|
||||
export class AccountLimitError extends Error {
|
||||
readonly limit = MAX_ACCOUNTS;
|
||||
|
||||
constructor() {
|
||||
super(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
||||
this.name = 'AccountLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export function generateAccountId(username: string, serverUrl: string): string {
|
||||
let host = serverUrl;
|
||||
try {
|
||||
|
||||
+85
-6
@@ -5,6 +5,7 @@
|
||||
//
|
||||
// Errors are matched by `name` and message text rather than `instanceof` so
|
||||
// this module stays free of the api/ and expo dependency graph.
|
||||
import { MAX_ACCOUNTS } from './account-utils';
|
||||
|
||||
export interface LoginErrorCopy {
|
||||
title: string;
|
||||
@@ -34,6 +35,70 @@ function hostLabel(serverUrl: string | null | undefined, fallback: string): stri
|
||||
return host || fallback;
|
||||
}
|
||||
|
||||
// A QR / link sign-in failure (`PairingError` in lib/oauth), by reason.
|
||||
function describePairingError(reason: string | undefined, host: string, t: Translate): LoginErrorCopy | null {
|
||||
switch (reason) {
|
||||
case 'expired':
|
||||
return {
|
||||
title: t('login.mobile.err_code_expired_title', 'That code has expired'),
|
||||
detail: t('login.mobile.err_code_expired_detail', 'Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.'),
|
||||
};
|
||||
case 'used':
|
||||
return {
|
||||
title: t('login.mobile.err_code_used_title', 'That code was already used'),
|
||||
detail: t('login.mobile.err_code_used_detail', 'Each code works once. Show a new one in the webmail and scan again.'),
|
||||
};
|
||||
case 'expired_or_used':
|
||||
return {
|
||||
title: t('login.mobile.err_code_expired_or_used_title', 'That code has expired or was already used'),
|
||||
detail: t('login.mobile.err_code_expired_or_used_detail', 'Show a new one in the webmail and scan again.'),
|
||||
};
|
||||
case 'invalid':
|
||||
return {
|
||||
title: t('login.mobile.err_code_invalid_title', "That code isn't valid"),
|
||||
detail: t('login.mobile.err_code_invalid_detail', 'Show a new code in the webmail (Settings → Security → Link Mobile App) and scan it.'),
|
||||
};
|
||||
case 'unsupported':
|
||||
return {
|
||||
title: t('login.mobile.err_pair_unsupported_title', "This webmail can't link devices"),
|
||||
detail: t('login.mobile.err_pair_unsupported_detail', "{host} doesn't support sign-in codes. It may need an update, or the link points to the wrong address.", { host }),
|
||||
};
|
||||
case 'untrusted':
|
||||
return {
|
||||
title: t('login.mobile.err_pair_untrusted_title', "This sign-in can't be trusted"),
|
||||
detail: t('login.mobile.err_pair_untrusted_detail', "{host} sent sign-in details for a server the app can't verify.", { host }),
|
||||
};
|
||||
case 'insecure':
|
||||
return {
|
||||
title: t('login.mobile.err_pair_untrusted_title', "This sign-in can't be trusted"),
|
||||
detail: t('login.mobile.err_pair_insecure_detail', '{host} wants to use an unencrypted connection.', { host }),
|
||||
};
|
||||
case 'network':
|
||||
return {
|
||||
title: t('login.mobile.err_unreachable_title', "Can't reach {host}", { host }),
|
||||
detail: t('login.mobile.err_pair_network_detail', 'Check your connection and that your phone can reach the webmail, then try again.'),
|
||||
};
|
||||
case 'rate_limited':
|
||||
return {
|
||||
title: t('login.mobile.err_rate_title', 'Too many attempts'),
|
||||
detail: t('login.mobile.err_pair_rate_detail', 'Wait a minute, then try again.'),
|
||||
};
|
||||
case 'server':
|
||||
case 'bad_response':
|
||||
return {
|
||||
title: t('login.mobile.err_pair_server_title', "The webmail couldn't complete the sign-in"),
|
||||
detail: t('login.mobile.err_pair_server_detail', 'Try again with a new code. If it keeps failing, ask your administrator.'),
|
||||
};
|
||||
case 'connect_failed':
|
||||
return {
|
||||
title: t('login.mobile.err_pair_connect_title', "Signed in, but couldn't connect to {host}", { host }),
|
||||
detail: t('login.mobile.err_pair_connect_detail', 'Show a new code on your computer and scan again.'),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
|
||||
const name = nameOf(err);
|
||||
const message = messageOf(err);
|
||||
@@ -45,6 +110,15 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
||||
});
|
||||
const host = hostLabel(context.serverUrl, t('login.mobile.the_server', 'the server'));
|
||||
|
||||
if (name === 'PairingError') {
|
||||
const pairing = err as { reason?: string; host?: string };
|
||||
// The error names the host it is about: the webmail for a failed redeem,
|
||||
// the mail server once the code was redeemed (`connect_failed`).
|
||||
const pairingHost = pairing.host || host;
|
||||
const copy = describePairingError(pairing.reason, pairingHost, t);
|
||||
if (copy) return copy;
|
||||
}
|
||||
|
||||
if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) {
|
||||
return {
|
||||
title: t('login.mobile.err_totp_title', 'Enter your two-factor code'),
|
||||
@@ -135,11 +209,10 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
||||
};
|
||||
}
|
||||
|
||||
// The store keeps a failed pairing as a plain message; without the reason,
|
||||
// say both.
|
||||
if (lower.includes('pairing code')) {
|
||||
return {
|
||||
title: t('login.mobile.err_code_expired_title', 'That code has expired'),
|
||||
detail: t('login.mobile.err_code_expired_detail', 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.'),
|
||||
};
|
||||
return describePairingError('expired_or_used', host, t)!;
|
||||
}
|
||||
|
||||
if (lower.includes('state mismatch')) {
|
||||
@@ -149,8 +222,14 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('maximum of') && lower.includes('accounts')) {
|
||||
return { title: message };
|
||||
// No room for another account (AccountLimitError, or its message as the
|
||||
// store keeps it).
|
||||
if (name === 'AccountLimitError' || /maximum of \d+ accounts/i.test(message)) {
|
||||
const count = Number(/maximum of (\d+)/i.exec(message)?.[1]) || MAX_ACCOUNTS;
|
||||
return {
|
||||
title: t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count }),
|
||||
detail: t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
|
||||
};
|
||||
}
|
||||
|
||||
// Unrecognised: show what we were told rather than inventing a cause.
|
||||
|
||||
+265
-52
@@ -19,9 +19,12 @@ export interface OAuthTokens {
|
||||
expiresAt?: number; // epoch ms
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
// Where the bundle came from. A paired phone shares the desktop's refresh
|
||||
// token, so revoking it on sign-out would also sign the desktop out;
|
||||
// every other source is safe to revoke.
|
||||
// Where the bundle came from. A paired phone gets its own grant: the
|
||||
// webmail mints a separate one for the device rather than handing over
|
||||
// the desktop's session. Its refresh token may be an opaque string only
|
||||
// the webmail's token proxy understands, and webmail up to 1.11 did share
|
||||
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
|
||||
// of revoking them at the mail server; every other source is revoked.
|
||||
source?: OAuthTokenSource;
|
||||
}
|
||||
|
||||
@@ -56,6 +59,44 @@ export class TransientRefreshError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Why a QR / link sign-in failed, so the login screen can say what to do next
|
||||
// instead of one catch-all "expired" message.
|
||||
// expired / used / expired_or_used / invalid: the code itself (webmail up to
|
||||
// 1.11 does not say which, hence `expired_or_used`)
|
||||
// unsupported: no redeem route at that address (old webmail, wrong link)
|
||||
// untrusted / insecure: the answer names a token endpoint or server the app
|
||||
// will not send credentials to
|
||||
// network / server / rate_limited / bad_response: the redeem request itself
|
||||
// connect_failed: the code was redeemed, but signing in to the mail server
|
||||
// with what it returned failed; the code is gone, a new one is needed
|
||||
export type PairingErrorReason =
|
||||
| 'expired'
|
||||
| 'used'
|
||||
| 'expired_or_used'
|
||||
| 'invalid'
|
||||
| 'unsupported'
|
||||
| 'untrusted'
|
||||
| 'insecure'
|
||||
| 'network'
|
||||
| 'server'
|
||||
| 'rate_limited'
|
||||
| 'bad_response'
|
||||
| 'connect_failed';
|
||||
|
||||
export class PairingError extends Error {
|
||||
readonly reason: PairingErrorReason;
|
||||
/** Host the message is about: the webmail, or the mail server for `connect_failed`. */
|
||||
readonly host?: string;
|
||||
|
||||
constructor(reason: PairingErrorReason, message?: string, opts?: { host?: string; cause?: unknown }) {
|
||||
super(message ?? `Pairing failed: ${reason}`);
|
||||
this.name = 'PairingError';
|
||||
this.reason = reason;
|
||||
this.host = opts?.host;
|
||||
if (opts?.cause !== undefined) this.cause = opts.cause;
|
||||
}
|
||||
}
|
||||
|
||||
// The state is the only guard against a forged `bulwarkmobile://` redirect
|
||||
// delivering foreign credentials, so it comes from the platform CSPRNG.
|
||||
function randomState(): string {
|
||||
@@ -191,24 +232,35 @@ export async function runWebmailHandoff(
|
||||
// server URL for the normal webmail handoff (`connect`), or carries a one-time
|
||||
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
|
||||
// The payload never contains credentials — the `pair` code is redeemed for
|
||||
// tokens over the network, once.
|
||||
// tokens over the network, once. The same `bulwarkmail://` links also arrive
|
||||
// as deep links (a tapped link) and as pasted text.
|
||||
export type QrLoginPayload =
|
||||
| { kind: 'connect'; webmailUrl: string }
|
||||
| { kind: 'pair'; webmailUrl: string; code: string };
|
||||
|
||||
// `bulwarkmail://pair?…` and `bulwarkmail://pair/?…` (Android and some QR
|
||||
// readers add the slash after the host).
|
||||
const SIGN_IN_LINK_RE = /^bulwarkmail:\/\/(connect|pair)\/?\?([^#]*)(?:#.*)?$/i;
|
||||
// The webmail mints 64 hex characters; accept any URL-safe token of sane
|
||||
// length so a future format does not break older apps.
|
||||
const PAIRING_CODE_RE = /^[A-Za-z0-9_-]{8,512}$/;
|
||||
|
||||
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
|
||||
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
|
||||
const match = SIGN_IN_LINK_RE.exec(trimmed);
|
||||
if (match) {
|
||||
const kind = match[1].toLowerCase();
|
||||
const params = new URLSearchParams(match[2]);
|
||||
const server = params.get('server');
|
||||
if (!server || !/^https?:\/\//i.test(server)) return null;
|
||||
const server = params.get('server')?.trim();
|
||||
if (!server || !/^https?:\/\/[^/?#\s]+/i.test(server)) return null;
|
||||
if (kind === 'pair') {
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
// The code is redeemed at this address, and the answer carries
|
||||
// credentials: only over TLS, or plain http on loopback for development.
|
||||
if (!isHttpsUrl(server) && !isLoopbackHttp(server)) return null;
|
||||
const code = params.get('code')?.trim();
|
||||
if (!code || !PAIRING_CODE_RE.test(code)) return null;
|
||||
return { kind: 'pair', webmailUrl: server, code };
|
||||
}
|
||||
return { kind: 'connect', webmailUrl: server };
|
||||
@@ -223,59 +275,207 @@ export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The code is redeemed at the webmail, and the answer carries credentials:
|
||||
* only over TLS, or plain http on loopback for development.
|
||||
*/
|
||||
export function isInsecurePairingUrl(webmailUrl: string): boolean {
|
||||
return !isHttpsUrl(webmailUrl) && !isLoopbackHttp(webmailUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host a sign-in link talks to, for the user to check. Read the way the
|
||||
* network stack reads the address: `https://mail.example.com@evil.example`
|
||||
* and `https://evil.example\@mail.example.com` both go to evil.example, so
|
||||
* that is the host named.
|
||||
*/
|
||||
export function signInLinkHost(url: string): string {
|
||||
const authority = url.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/\\?#]/)[0];
|
||||
return authority.slice(authority.lastIndexOf('@') + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Why parseQrLoginPayload refused a pairing link, when the reason is its
|
||||
* plain-http webmail (the code, and the credentials it buys, would cross the
|
||||
* network in clear), so a scanned, pasted or tapped link can say so instead
|
||||
* of "not a Bulwark sign-in code". Only ever an error to show: the parser
|
||||
* stays the one gate and never yields such a link. Takes the link alone or
|
||||
* pasted text around it; null for anything else.
|
||||
*/
|
||||
export function insecurePairingLinkError(text: string): PairingError | null {
|
||||
const link = (/bulwarkmail:\/\/\S+/i.exec(text)?.[0] ?? '').replace(/[)\]>.,;'"]+$/, '');
|
||||
const match = SIGN_IN_LINK_RE.exec(link);
|
||||
if (!match || match[1].toLowerCase() !== 'pair') return null;
|
||||
const params = new URLSearchParams(match[2]);
|
||||
const server = params.get('server')?.trim();
|
||||
const code = params.get('code')?.trim();
|
||||
if (!server || !/^http:\/\/[^/?#\s]+/i.test(server) || !isInsecurePairingUrl(server)) return null;
|
||||
if (!code || !PAIRING_CODE_RE.test(code)) return null;
|
||||
return new PairingError('insecure', 'Pairing needs an https webmail address', {
|
||||
host: signInLinkHost(server),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pasted text: the link on its own, or a message that contains it ("Open
|
||||
* bulwarkmail://pair?… on your phone"). Falls back to the whole text so a
|
||||
* pasted webmail address still works like a scanned one.
|
||||
*/
|
||||
export function parsePastedSignInLink(text: string): QrLoginPayload | null {
|
||||
const embedded = /bulwarkmail:\/\/\S+/i.exec(text)?.[0];
|
||||
if (embedded) {
|
||||
const payload = parseQrLoginPayload(embedded.replace(/[)\]>.,;'"]+$/, ''));
|
||||
if (payload) return payload;
|
||||
}
|
||||
return parseQrLoginPayload(text);
|
||||
}
|
||||
|
||||
export const PAIRING_REDEEM_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Host (and port) for messages: "Can't reach mail.example.com".
|
||||
function displayHost(url: string): string {
|
||||
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/?#]/)[0];
|
||||
}
|
||||
|
||||
// A JSON body, or null for an empty, HTML or otherwise unparseable one. A
|
||||
// proxy error page or a webmail too old to have the route answers with HTML.
|
||||
async function readJsonBody(response: Response): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const data: unknown = await response.json();
|
||||
return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = data[key];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
// Map a failed redeem onto what the user should do about it.
|
||||
function redeemFailure(status: number, body: Record<string, unknown> | null, host: string): PairingError {
|
||||
const error = body ? stringField(body, 'error') : undefined;
|
||||
if (status === 410) {
|
||||
if (error === 'expired_code') return new PairingError('expired', 'The pairing code has expired', { host });
|
||||
if (error === 'used_code') return new PairingError('used', 'The pairing code was already used', { host });
|
||||
return new PairingError('expired_or_used', 'The pairing code has expired or was already used', { host });
|
||||
}
|
||||
if (status === 400) {
|
||||
if (error === 'invalid_code') return new PairingError('invalid', 'The pairing code is not valid', { host });
|
||||
// Webmail up to 1.11 answers every failure with a 400 and
|
||||
// "Invalid or expired pairing code" / "Missing pairing code".
|
||||
return new PairingError('expired_or_used', 'The pairing code has expired or was already used', { host });
|
||||
}
|
||||
if (status === 404 || status === 405 || (status >= 300 && status < 400)) {
|
||||
return new PairingError('unsupported', `No pairing endpoint at ${host} (${status})`, { host });
|
||||
}
|
||||
if (status === 429) return new PairingError('rate_limited', 'Too many pairing attempts (429)', { host });
|
||||
return new PairingError('server', `Pairing failed with status ${status}`, { host });
|
||||
}
|
||||
|
||||
// Cross-device pairing redemption. Posts the scanned code to the webmail that
|
||||
// minted it and maps the token bundle into the same shape the in-browser OAuth
|
||||
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
|
||||
// minted it and maps the answer into the same shape the in-browser handoff
|
||||
// produces: an OAuth token bundle for connectWithOAuth, or an app password
|
||||
// (the account password on servers without app passwords) for login().
|
||||
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
let response: Response;
|
||||
const host = displayHost(base);
|
||||
// The login screen refuses these links already; never send a code in clear.
|
||||
if (isInsecurePairingUrl(base)) {
|
||||
throw new PairingError('insecure', 'Pairing needs an https webmail address', { host });
|
||||
}
|
||||
|
||||
// secureFetch's native (client-certificate) path ignores `signal`, so the
|
||||
// deadline is also passed as `timeoutMs` and enforced here with a race; the
|
||||
// body read counts against it too.
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new PairingError('network', `Timed out reaching ${host}`, { host }));
|
||||
}, PAIRING_REDEEM_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
let status: number;
|
||||
let ok: boolean;
|
||||
let data: Record<string, unknown> | null;
|
||||
try {
|
||||
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ pairing_code: code }),
|
||||
});
|
||||
} catch {
|
||||
throw new HandoffError('Could not reach the server to complete pairing');
|
||||
const exchange = (async () => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ pairing_code: code }),
|
||||
signal: controller.signal,
|
||||
timeoutMs: PAIRING_REDEEM_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new PairingError(
|
||||
'network',
|
||||
`Could not reach ${host}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ host, cause: err },
|
||||
);
|
||||
}
|
||||
return { status: response.status, ok: response.ok, data: await readJsonBody(response) };
|
||||
})();
|
||||
({ status, ok, data } = await Promise.race([exchange, deadline]));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// The code was unknown, already used, or expired (server returns 400) —
|
||||
// or something else went wrong. Either way the user needs a fresh QR.
|
||||
throw new HandoffError('Pairing code is invalid or has expired');
|
||||
if (!ok) throw redeemFailure(status, data, host);
|
||||
if (!data) throw new PairingError('bad_response', 'The pairing response was not JSON', { host });
|
||||
|
||||
const serverUrl = stringField(data, 'server_url');
|
||||
if (!serverUrl) throw new PairingError('bad_response', 'Pairing response missing server URL', { host });
|
||||
if (!isHttpsUrl(serverUrl) && !isLoopbackHttp(serverUrl)) {
|
||||
throw new PairingError('insecure', 'Pairing response server URL must use https', { host });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
server_url?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_endpoint?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
// Webmail up to 1.11 always sends `flow: 'oauth'`; a bundle without the
|
||||
// field but with a token is the same thing.
|
||||
const flow = stringField(data, 'flow') ?? (stringField(data, 'access_token') ? 'oauth' : undefined);
|
||||
|
||||
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
|
||||
throw new HandoffError('Pairing response missing token material');
|
||||
}
|
||||
if (!isHttpsUrl(data.server_url) && !isLoopbackHttp(data.server_url)) {
|
||||
throw new HandoffError('Pairing response server URL must use https');
|
||||
}
|
||||
if (!isAcceptableTokenEndpoint(data.token_endpoint, data.server_url, base)) {
|
||||
throw new HandoffError('Pairing response token endpoint is not trusted');
|
||||
if (flow === 'password') {
|
||||
const username = stringField(data, 'username');
|
||||
const password = stringField(data, 'password');
|
||||
if (!username || !password) {
|
||||
throw new PairingError('bad_response', 'Pairing response missing credentials', { host });
|
||||
}
|
||||
return { flow: 'password', serverUrl, username, password };
|
||||
}
|
||||
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl: data.server_url,
|
||||
tokens: {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? undefined,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: data.token_endpoint,
|
||||
clientId: data.client_id,
|
||||
source: 'pairing',
|
||||
},
|
||||
};
|
||||
if (flow === 'oauth') {
|
||||
const accessToken = stringField(data, 'access_token');
|
||||
const tokenEndpoint = stringField(data, 'token_endpoint');
|
||||
const clientId = stringField(data, 'client_id');
|
||||
if (!accessToken || !tokenEndpoint || !clientId) {
|
||||
throw new PairingError('bad_response', 'Pairing response missing token material', { host });
|
||||
}
|
||||
// The mail server's own host, or the webmail's token proxy
|
||||
// (`<webmail>/api/auth/pair/token`); nothing else gets the refresh token.
|
||||
if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, base)) {
|
||||
throw new PairingError('untrusted', 'Pairing response token endpoint is not trusted', { host });
|
||||
}
|
||||
const expiresIn = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) ? data.expires_in : undefined;
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken: stringField(data, 'refresh_token'),
|
||||
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
source: 'pairing',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new PairingError('bad_response', `Unknown pairing flow: ${flow ?? 'missing'}`, { host });
|
||||
}
|
||||
|
||||
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
|
||||
@@ -323,11 +523,24 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
||||
}
|
||||
throw new TransientRefreshError(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
// A 200 that is not JSON is a captive portal or a proxy page, not an
|
||||
// answer from the token endpoint; the refresh token may still be
|
||||
// good, so keep the account (a SyntaxError here used to evict it).
|
||||
let data: {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
try {
|
||||
data = (await response.json()) as typeof data;
|
||||
} catch (err) {
|
||||
throw new TransientRefreshError(
|
||||
`Token refresh response was not JSON: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new TransientRefreshError('Token refresh response was not a JSON object');
|
||||
}
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking';
|
||||
import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking';
|
||||
import { usePendingSignInLinkStore } from '../pending-sign-in-link';
|
||||
import { usePendingSettingsTab } from '../pending-settings-tab';
|
||||
import { usePendingCalendarOpen } from '../pending-calendar-open';
|
||||
import { usePendingMailSearch } from '../pending-mail-search';
|
||||
import { links } from '../../widgets/clicks';
|
||||
|
||||
describe('parseDeepLink', () => {
|
||||
it('parses app-scheme mail links', () => {
|
||||
@@ -80,6 +82,14 @@ describe('parseDeepLink', () => {
|
||||
expect(parseDeepLink('bulwarkmobile://mail/message/M1?account=a%40b&jmapAccount=team&action=reply')).toEqual({
|
||||
kind: 'message', emailId: 'M1', accountId: 'a@b', jmapAccountId: 'team', action: 'reply',
|
||||
});
|
||||
// What the widgets build is what the app reads, thread included.
|
||||
const m = { id: 'M/1', threadId: 'T1', accountId: 'a@b', jmapAccountId: 'team' };
|
||||
expect(parseDeepLink(links.reply(m))).toEqual({
|
||||
kind: 'message', emailId: 'M/1', accountId: 'a@b', jmapAccountId: 'team', threadId: 'T1', action: 'reply',
|
||||
});
|
||||
expect(parseDeepLink(links.message({ id: 'M2', accountId: 'a@b' }))).toEqual({
|
||||
kind: 'message', emailId: 'M2', accountId: 'a@b',
|
||||
});
|
||||
expect(parseDeepLink('bulwarkmobile://mail/draft/D1')).toEqual({ kind: 'draft', emailId: 'D1', accountId: undefined });
|
||||
expect(parseDeepLink('bulwarkmobile://mail/unified?view=starred')).toEqual({ kind: 'unified', view: 'starred' });
|
||||
expect(parseDeepLink('bulwarkmobile://mail/unified?role=drafts&view=bogus')).toEqual({ kind: 'unified', role: 'drafts' });
|
||||
@@ -179,7 +189,7 @@ describe('handleDeepLink', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('opens a group-mailbox message in its JMAP account and hands replies to the composer opener', async () => {
|
||||
it('opens a group-mailbox message in its JMAP account', async () => {
|
||||
const navigation = nav();
|
||||
const resolveThreadId = vi.fn(async () => 'T1');
|
||||
await handleDeepLink(
|
||||
@@ -188,14 +198,25 @@ describe('handleDeepLink', () => {
|
||||
);
|
||||
expect(resolveThreadId).toHaveBeenCalledWith('M1', 'team');
|
||||
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1', jmapAccountId: 'team' });
|
||||
});
|
||||
|
||||
const openReply = vi.fn(async () => true);
|
||||
it('opens a reply over its message, without a lookup when the link names the thread', async () => {
|
||||
// Offline, or before the session is back: the lookup would fail.
|
||||
const resolveThreadId = vi.fn(async () => null);
|
||||
const navigation = nav();
|
||||
const ok = await handleDeepLink(
|
||||
{ kind: 'message', emailId: 'M2', action: 'reply' },
|
||||
{ navigation: nav() as never, resolveThreadId, openReply },
|
||||
{ kind: 'message', emailId: 'M2', threadId: 'T2', action: 'reply' },
|
||||
{ navigation: navigation as never, resolveThreadId },
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
expect(openReply).toHaveBeenCalledWith('M2', undefined);
|
||||
expect(resolveThreadId).not.toHaveBeenCalled();
|
||||
expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M2', threadId: 'T2', action: 'reply' });
|
||||
|
||||
// Without the thread and without a server, nothing opens (the app says so).
|
||||
expect(await handleDeepLink(
|
||||
{ kind: 'message', emailId: 'M3', action: 'reply' },
|
||||
{ navigation: nav() as never, resolveThreadId },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('opens drafts, unified views, scheduled mail and searches', async () => {
|
||||
@@ -237,3 +258,61 @@ 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 from the OS as external, for the login screen to take once', () => {
|
||||
usePendingSignInLinkStore.getState().clear();
|
||||
expect(acceptSignInLink('bulwarkmobile://mail')).toBe(false);
|
||||
expect(acceptSignInLink('mailto:a@b.co')).toBe(false);
|
||||
expect(usePendingSignInLinkStore.getState().pending).toBeNull();
|
||||
|
||||
expect(acceptSignInLink(pair)).toBe(true);
|
||||
expect(usePendingSignInLinkStore.getState().pending).toMatchObject({
|
||||
source: 'external',
|
||||
payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code },
|
||||
});
|
||||
// Anyone can fire the link, so it is not run until the user confirms.
|
||||
expect(usePendingSignInLinkStore.getState().take()).toMatchObject({
|
||||
needsConfirmation: true,
|
||||
payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code },
|
||||
});
|
||||
// Taken means cleared: a second delivery of the same state signs in nothing.
|
||||
expect(usePendingSignInLinkStore.getState().pending).toBeNull();
|
||||
expect(usePendingSignInLinkStore.getState().take()).toBeNull();
|
||||
usePendingSignInLinkStore.getState().clear();
|
||||
});
|
||||
|
||||
it('parks connect links as external too', () => {
|
||||
usePendingSignInLinkStore.getState().clear();
|
||||
expect(acceptSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBe(true);
|
||||
expect(usePendingSignInLinkStore.getState().take()).toMatchObject({
|
||||
needsConfirmation: true,
|
||||
payload: { kind: 'connect', webmailUrl: 'https://mail.example.com' },
|
||||
});
|
||||
usePendingSignInLinkStore.getState().clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { acceptSignInLink, routeParkedSignInLink } from '../linking';
|
||||
import {
|
||||
setPendingSignInLink,
|
||||
signInLinkNeedsConfirmation,
|
||||
usePendingSignInLinkStore,
|
||||
} from '../pending-sign-in-link';
|
||||
import type { QrLoginPayload } from '../../lib/oauth';
|
||||
import { MAX_ACCOUNTS } from '../../lib/account-utils';
|
||||
|
||||
// A `bulwarkmail://` link from outside the app (any web page, one tap; any
|
||||
// installed app, no tap) names a webmail and a code of the sender's choosing.
|
||||
// It must not be redeemed or opened until the user says Continue; a scanned
|
||||
// or pasted one is the user's own doing in the app and runs straight away.
|
||||
|
||||
const code = 'c0ffee'.repeat(10) + 'beef';
|
||||
const link = (server: string) =>
|
||||
`bulwarkmail://pair?server=${encodeURIComponent(server)}&code=${code}`;
|
||||
const ATTACKER = link('https://attacker.example');
|
||||
const OTHER = link('https://other.example');
|
||||
|
||||
const store = () => usePendingSignInLinkStore.getState();
|
||||
|
||||
beforeEach(() => store().clear());
|
||||
|
||||
describe('sign-in link confirmation', () => {
|
||||
it('asks for links from outside the app, not for scanned or pasted ones', () => {
|
||||
expect(signInLinkNeedsConfirmation('external')).toBe(true);
|
||||
expect(signInLinkNeedsConfirmation('in-app')).toBe(false);
|
||||
});
|
||||
|
||||
it('holds an external link until the user continues, then runs it once', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
const taken = store().take();
|
||||
expect(taken).toMatchObject({ needsConfirmation: true, payload: { kind: 'pair', webmailUrl: 'https://attacker.example', code } });
|
||||
// Taken for the dialog, not handed out to run.
|
||||
expect(store().pending).toBeNull();
|
||||
expect(store().awaitingConfirmation?.id).toBe(taken!.id);
|
||||
expect(store().take()).toBeNull();
|
||||
|
||||
expect(store().confirm(taken!.id)).toEqual({ kind: 'pair', webmailUrl: 'https://attacker.example', code });
|
||||
expect(store().awaitingConfirmation).toBeNull();
|
||||
// A second Continue (or a stale dialog) runs nothing.
|
||||
expect(store().confirm(taken!.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets a cancelled link, so nothing redeems it later', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
const taken = store().take()!;
|
||||
expect(store().discard(taken.id)).toBe(true);
|
||||
expect(store().confirm(taken.id)).toBeNull();
|
||||
expect(store().take()).toBeNull();
|
||||
expect(store().pending).toBeNull();
|
||||
expect(store().awaitingConfirmation).toBeNull();
|
||||
expect(store().discard(taken.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops links delivered while the dialog is open instead of swapping them in', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
const shown = store().take()!;
|
||||
// A second 'url' event, or getInitialURL delivering again.
|
||||
expect(acceptSignInLink(OTHER)).toBe(true);
|
||||
expect(acceptSignInLink(ATTACKER)).toBe(true);
|
||||
expect(store().pending).toBeNull();
|
||||
expect(store().take()).toBeNull();
|
||||
// Continue runs the link the dialog named, nothing else.
|
||||
expect(store().confirm(shown.id)).toMatchObject({ webmailUrl: 'https://attacker.example' });
|
||||
expect(store().take()).toBeNull();
|
||||
});
|
||||
|
||||
it('asks again when a link is delivered again after the dialog closed', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
const first = store().take()!;
|
||||
store().discard(first.id);
|
||||
|
||||
acceptSignInLink(ATTACKER);
|
||||
const again = store().take()!;
|
||||
expect(again.needsConfirmation).toBe(true);
|
||||
expect(again.id).not.toBe(first.id);
|
||||
// The cancelled dialog's id cannot confirm the new delivery.
|
||||
expect(store().confirm(first.id)).toBeNull();
|
||||
expect(store().awaitingConfirmation?.id).toBe(again.id);
|
||||
});
|
||||
|
||||
it('keeps the newest of two links parked before the login screen takes one', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
acceptSignInLink(OTHER);
|
||||
expect(store().take()).toMatchObject({ needsConfirmation: true, payload: { webmailUrl: 'https://other.example' } });
|
||||
});
|
||||
|
||||
it('runs an in-app link without a dialog', () => {
|
||||
const payload: QrLoginPayload = { kind: 'connect', webmailUrl: 'https://mail.example.com' };
|
||||
expect(setPendingSignInLink(payload, 'in-app')).toBe(true);
|
||||
expect(store().take()).toMatchObject({ needsConfirmation: false, payload });
|
||||
expect(store().awaitingConfirmation).toBeNull();
|
||||
expect(store().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refused sign-in links', () => {
|
||||
it('parks a pairing link for a plain-http webmail as a refusal, with nothing to run', () => {
|
||||
expect(acceptSignInLink(link('http://mail.example.com'))).toBe(true);
|
||||
expect(store().pending).toBeNull();
|
||||
expect(store().take()).toBeNull();
|
||||
const refusal = store().takeRefusal();
|
||||
expect(refusal).toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'mail.example.com' });
|
||||
expect(store().takeRefusal()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores other broken sign-in links, as before', () => {
|
||||
expect(acceptSignInLink(`bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com')}`)).toBe(false);
|
||||
expect(acceptSignInLink('bulwarkmail://unlock?server=https://mail.example.com')).toBe(false);
|
||||
expect(store().refusal).toBeNull();
|
||||
expect(store().pending).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeParkedSignInLink (signed in)', () => {
|
||||
it('opens Add account while there is room', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
expect(routeParkedSignInLink(MAX_ACCOUNTS - 1)).toBe('add-account');
|
||||
// Still parked for Add account's login screen.
|
||||
expect(store().pending).not.toBeNull();
|
||||
});
|
||||
|
||||
it('drops the link and reports the limit when the registry is full, so no code is spent', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit');
|
||||
expect(store().pending).toBeNull();
|
||||
expect(store().take()).toBeNull();
|
||||
|
||||
acceptSignInLink(link('http://mail.example.com'));
|
||||
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit');
|
||||
expect(store().refusal).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when no link is parked', () => {
|
||||
expect(routeParkedSignInLink(0)).toBe('none');
|
||||
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none');
|
||||
});
|
||||
|
||||
it('leaves a link the dialog is asking about alone', () => {
|
||||
acceptSignInLink(ATTACKER);
|
||||
const shown = store().take()!;
|
||||
expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none');
|
||||
expect(store().confirm(shown.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,15 @@ 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, usePendingSignInLinkStore } from './pending-sign-in-link';
|
||||
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
|
||||
import { MAX_ACCOUNTS } from '../lib/account-utils';
|
||||
|
||||
export const APP_SCHEME = 'bulwarkmobile';
|
||||
// The webmail's "Link Mobile App" links (`bulwarkmail://pair?server=…&code=…`)
|
||||
// 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;
|
||||
@@ -19,8 +26,9 @@ type UnifiedView = typeof UNIFIED_VIEWS[number];
|
||||
|
||||
export type DeepLink =
|
||||
// `jmapAccountId`: the JMAP account owning a message in a group/shared
|
||||
// mailbox (`?jmapAccount=`); `action: 'reply'` opens a reply to it.
|
||||
| { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; action?: 'reply' }
|
||||
// mailbox (`?jmapAccount=`); `threadId` (`?thread=`) saves looking it up;
|
||||
// `action: 'reply'` opens a reply to it.
|
||||
| { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; threadId?: string; action?: 'reply' }
|
||||
| { kind: 'draft'; emailId: string; accountId?: string; jmapAccountId?: string }
|
||||
| { kind: 'thread'; threadId: string; accountId?: string }
|
||||
| { kind: 'folder'; ref: string; accountId?: string }
|
||||
@@ -73,6 +81,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;
|
||||
@@ -96,11 +105,13 @@ export function parseDeepLink(url: string): DeepLink | null {
|
||||
case 'mail': {
|
||||
const jmapAccountId = search.get('jmapAccount') ?? undefined;
|
||||
if (kind === 'message' && value) {
|
||||
const threadId = search.get('thread');
|
||||
return {
|
||||
kind: 'message',
|
||||
emailId: decodeSegment(value),
|
||||
accountId,
|
||||
...(jmapAccountId ? { jmapAccountId } : {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(search.get('action') === 'reply' ? { action: 'reply' as const } : {}),
|
||||
};
|
||||
}
|
||||
@@ -163,15 +174,60 @@ 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 sign-in link the OS delivered (a tapped link, or another app firing
|
||||
* the intent) for the login screen, which takes it (the signed-out one, or
|
||||
* Add account when someone is signed in) and asks the user before running
|
||||
* it: whoever sent it chose the webmail and the code. A pairing link for a
|
||||
* plain-http webmail is parked as a refusal instead, for the login screen to
|
||||
* say why nothing happens. Returns whether `url` was a sign-in link.
|
||||
*/
|
||||
export function acceptSignInLink(url: string | null | undefined): boolean {
|
||||
const payload = parseSignInLink(url);
|
||||
if (payload) {
|
||||
setPendingSignInLink(payload, 'external');
|
||||
return true;
|
||||
}
|
||||
const refusal = url && isSignInSchemeUrl(url) ? insecurePairingLinkError(url) : null;
|
||||
if (!refusal) return false;
|
||||
usePendingSignInLinkStore.getState().refuse(refusal);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed in, where a parked sign-in link goes: Add account, whose login
|
||||
* screen takes it, or, with no room for another account, nowhere: the link
|
||||
* is dropped before a code is spent and the caller shows the limit. 'none'
|
||||
* when nothing is parked (taken meanwhile by an Add account already open).
|
||||
*/
|
||||
export function routeParkedSignInLink(accountCount: number): 'add-account' | 'account-limit' | 'none' {
|
||||
const links = usePendingSignInLinkStore.getState();
|
||||
if (!links.pending && !links.refusal) return 'none';
|
||||
if (accountCount >= MAX_ACCOUNTS) {
|
||||
links.dropParked();
|
||||
return 'account-limit';
|
||||
}
|
||||
return 'add-account';
|
||||
}
|
||||
|
||||
export interface DeepLinkNavigator {
|
||||
navigation: NavigationContainerRefWithCurrent<RootStackParamList>;
|
||||
// Resolve a message id to its thread (EmailThread needs both). Returns
|
||||
// null when the message cannot be loaded. `jmapAccountId` names the
|
||||
// group/shared account holding it, when it is not the user's own.
|
||||
resolveThreadId: (emailId: string, jmapAccountId?: string) => Promise<string | null>;
|
||||
// Open the composer on a reply to / on a server draft of this message.
|
||||
// Resolve false when the message cannot be loaded.
|
||||
openReply?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
|
||||
// Open the composer on a server draft. Resolve false when the draft cannot
|
||||
// be loaded.
|
||||
openDraft?: (emailId: string, jmapAccountId?: string) => Promise<boolean>;
|
||||
// Switch to the account a permalink names (`?account=`); resolves false
|
||||
// when that account is not signed in on this device.
|
||||
@@ -189,15 +245,16 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
|
||||
|
||||
switch (link.kind) {
|
||||
case 'message': {
|
||||
if (link.action === 'reply' && nav.openReply) {
|
||||
return nav.openReply(link.emailId, link.jmapAccountId);
|
||||
}
|
||||
const threadId = await nav.resolveThreadId(link.emailId, link.jmapAccountId);
|
||||
const threadId = link.threadId ?? await nav.resolveThreadId(link.emailId, link.jmapAccountId);
|
||||
if (!threadId) return false;
|
||||
// A reply opens over the message, like the reader's own Reply: the
|
||||
// reader loads it (from its cache when offline) and then opens the
|
||||
// composer, or shows why it could not.
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: link.emailId,
|
||||
threadId,
|
||||
...(link.jmapAccountId ? { jmapAccountId: link.jmapAccountId } : {}),
|
||||
...(link.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { create } from 'zustand';
|
||||
import type { PairingError, QrLoginPayload } from '../lib/oauth';
|
||||
|
||||
// A tapped `bulwarkmail://` sign-in link arrives before the login screen is
|
||||
// up (cold start, or signed in with Add account not open yet). It is parked
|
||||
// here and taken by LoginScreen, which runs it like a scanned code. Taking
|
||||
// clears it, so one link signs in once however often it is delivered.
|
||||
//
|
||||
// Where a link came from decides whether the user is asked first:
|
||||
// - `external`: the OS handed it to the app. Any web page (one tap) or any
|
||||
// installed app (no tap at all) can fire one, with a webmail and a code of
|
||||
// its own choosing, so the user confirms before it is redeemed or opened.
|
||||
// - `in-app`: scanned with the app's scanner or pasted into its paste box.
|
||||
// The user just did that in the app, so it runs straight away.
|
||||
export type SignInLinkSource = 'external' | 'in-app';
|
||||
|
||||
export interface PendingSignInLink {
|
||||
// Tells one delivery from the next, so a dialog can only ever confirm the
|
||||
// link it named.
|
||||
id: number;
|
||||
payload: QrLoginPayload;
|
||||
source: SignInLinkSource;
|
||||
}
|
||||
|
||||
export interface TakenSignInLink {
|
||||
id: number;
|
||||
payload: QrLoginPayload;
|
||||
// Show the confirmation and run the link only on Continue (`confirm`).
|
||||
needsConfirmation: boolean;
|
||||
}
|
||||
|
||||
export function signInLinkNeedsConfirmation(source: SignInLinkSource): boolean {
|
||||
return source !== 'in-app';
|
||||
}
|
||||
|
||||
interface PendingSignInLinkState {
|
||||
// Parked, not taken by a login screen yet.
|
||||
pending: PendingSignInLink | null;
|
||||
// Taken and shown to the user, waiting for Continue or Cancel.
|
||||
awaitingConfirmation: PendingSignInLink | null;
|
||||
// A tapped link that is refused outright (a pairing code for a plain-http
|
||||
// webmail): nothing to run or confirm, the login screen says why.
|
||||
refusal: PairingError | null;
|
||||
/** Park a link. Returns false when it was dropped (a dialog is open). */
|
||||
park: (payload: QrLoginPayload, source: SignInLinkSource) => boolean;
|
||||
/**
|
||||
* Take the parked link. One that needs confirmation moves to
|
||||
* `awaitingConfirmation`; run it only with what `confirm` returns.
|
||||
*/
|
||||
take: () => TakenSignInLink | null;
|
||||
/** Continue: the payload to run, or null when that link is gone. */
|
||||
confirm: (id: number) => QrLoginPayload | null;
|
||||
/**
|
||||
* Cancel: forget the link, so nothing redeems it later. Returns whether it
|
||||
* was still waiting.
|
||||
*/
|
||||
discard: (id: number) => boolean;
|
||||
refuse: (refusal: PairingError) => void;
|
||||
takeRefusal: () => PairingError | null;
|
||||
/** Drop what is parked (not a link a dialog is asking about). */
|
||||
dropParked: () => boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export const usePendingSignInLinkStore = create<PendingSignInLinkState>((set, get) => ({
|
||||
pending: null,
|
||||
awaitingConfirmation: null,
|
||||
refusal: null,
|
||||
park: (payload, source) => {
|
||||
// While the user is asked about one link, another is dropped rather
|
||||
// than queued behind it or swapped in: Continue must run the link the
|
||||
// dialog names and nothing else, and a link fired while the dialog is
|
||||
// open must not pop up right after Cancel.
|
||||
if (get().awaitingConfirmation) return false;
|
||||
set({ pending: { id: nextId++, payload, source } });
|
||||
return true;
|
||||
},
|
||||
take: () => {
|
||||
const { pending, awaitingConfirmation } = get();
|
||||
if (!pending || awaitingConfirmation) return null;
|
||||
const needsConfirmation = signInLinkNeedsConfirmation(pending.source);
|
||||
set({ pending: null, awaitingConfirmation: needsConfirmation ? pending : null });
|
||||
return { id: pending.id, payload: pending.payload, needsConfirmation };
|
||||
},
|
||||
confirm: (id) => {
|
||||
const link = get().awaitingConfirmation;
|
||||
if (!link || link.id !== id) return null;
|
||||
set({ awaitingConfirmation: null });
|
||||
return link.payload;
|
||||
},
|
||||
discard: (id) => {
|
||||
if (get().awaitingConfirmation?.id !== id) return false;
|
||||
set({ awaitingConfirmation: null });
|
||||
return true;
|
||||
},
|
||||
refuse: (refusal) => set({ refusal }),
|
||||
takeRefusal: () => {
|
||||
const refusal = get().refusal;
|
||||
if (refusal) set({ refusal: null });
|
||||
return refusal;
|
||||
},
|
||||
dropParked: () => {
|
||||
const { pending, refusal } = get();
|
||||
if (!pending && !refusal) return false;
|
||||
set({ pending: null, refusal: null });
|
||||
return true;
|
||||
},
|
||||
clear: () => set({ pending: null, awaitingConfirmation: null, refusal: null }),
|
||||
}));
|
||||
|
||||
export function setPendingSignInLink(payload: QrLoginPayload, source: SignInLinkSource): boolean {
|
||||
return usePendingSignInLinkStore.getState().park(payload, source);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export type RootStackParamList = {
|
||||
* viewer pages over the store's current folder.
|
||||
*/
|
||||
emailIds?: string[];
|
||||
/** Open the composer on a reply once the message has loaded (widget Reply). */
|
||||
action?: 'reply';
|
||||
};
|
||||
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
|
||||
Compose:
|
||||
|
||||
@@ -536,6 +536,15 @@ function EmailViewer({ route, navigation }: Props) {
|
||||
if (params) navigation.navigate('Compose', params);
|
||||
}, [email, navigation, ownerAccountId]);
|
||||
|
||||
// Opened to reply (a widget's Reply): the composer follows as soon as the
|
||||
// message is here, once. If it never loads, the reader's error says why.
|
||||
const replyPending = React.useRef(route.params.action === 'reply');
|
||||
React.useEffect(() => {
|
||||
if (!replyPending.current || email?.id !== route.params.emailId) return;
|
||||
replyPending.current = false;
|
||||
navigateCompose('reply', email);
|
||||
}, [email, route.params.emailId, navigateCompose]);
|
||||
|
||||
// Forward the raw message as a message/rfc822 attachment (webmail 1.8.1).
|
||||
const onForwardAsAttachment = () => {
|
||||
setMoreMenuOpen(false);
|
||||
|
||||
+202
-44
@@ -1,11 +1,18 @@
|
||||
import React from 'react';
|
||||
import { BackHandler } from 'react-native';
|
||||
import { Alert, BackHandler } from 'react-native';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { QrScanModal } from '../components/QrScanModal';
|
||||
import { parseQrLoginPayload } from '../lib/oauth';
|
||||
import { PasteSignInLinkModal } from '../components/PasteSignInLinkModal';
|
||||
import {
|
||||
insecurePairingLinkError,
|
||||
parseQrLoginPayload,
|
||||
signInLinkHost,
|
||||
type QrLoginPayload,
|
||||
} from '../lib/oauth';
|
||||
import { usePendingSignInLinkStore } from '../navigation/pending-sign-in-link';
|
||||
import {
|
||||
discoverServerForEmail,
|
||||
emailDomain,
|
||||
@@ -31,6 +38,31 @@ type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
|
||||
|
||||
const RECENT_EMAILS_KEY = 'login:recentEmails:v1';
|
||||
|
||||
// The question asked before running a sign-in link from outside the app:
|
||||
// which webmail it talks to, and whether it adds an account (and switches to
|
||||
// it) or signs in.
|
||||
function signInLinkConfirmation(
|
||||
payload: QrLoginPayload,
|
||||
isAddMode: boolean,
|
||||
t: TranslateFn,
|
||||
): { title: string; message: string } {
|
||||
const host = signInLinkHost(payload.webmailUrl);
|
||||
if (payload.kind === 'connect') {
|
||||
return {
|
||||
title: t('login.mobile.link_confirm_connect_title', 'Open the sign-in page of {host}?', { host }),
|
||||
message: isAddMode
|
||||
? t('login.mobile.link_confirm_connect_add', 'A link asks to add an account by signing in on this page. Only continue if you use {host} for your mail.', { host })
|
||||
: t('login.mobile.link_confirm_connect', 'A link asks to sign in on this page. Only continue if you use {host} for your mail.', { host }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t('login.mobile.link_confirm_title', 'Sign in with a link?'),
|
||||
message: isAddMode
|
||||
? t('login.mobile.link_confirm_pair_add', 'This link adds an account from {host} and switches the app to it. Only continue if you just showed a sign-in code on your computer.', { host })
|
||||
: t('login.mobile.link_confirm_pair', 'This link signs in to an account from {host}. Only continue if you just showed a sign-in code on your computer.', { host }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in, one question per screen, cheapest question first.
|
||||
*
|
||||
@@ -69,6 +101,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 +167,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 +182,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 +200,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 +232,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 +322,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 +338,156 @@ 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) {
|
||||
// A pairing code for a plain-http webmail: say why it won't be used.
|
||||
const insecure = insecurePairingLinkError(data);
|
||||
if (insecure) {
|
||||
setNotice(describeLoginError(insecure, { t }));
|
||||
return;
|
||||
}
|
||||
setNotice({
|
||||
title: t('login.mobile.notice_bad_qr', "That code isn't a Bulwark sign-in code"),
|
||||
detail: t('login.mobile.notice_bad_qr_detail', 'Open Bulwark on the web, then Settings → Security → Link Mobile App to show one.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void handleSignInLink(payload);
|
||||
},
|
||||
[handleSignInLink, t],
|
||||
);
|
||||
|
||||
// A sign-in link from outside the app (a tapped link, or one another app
|
||||
// fired) is parked by App until a login screen is free to take it; taking
|
||||
// clears it, so one link runs once. Whoever sent it chose the webmail and
|
||||
// the code, so the user confirms first: nothing is redeemed or opened
|
||||
// before Continue, and Cancel (or dismissing the dialog) forgets the link.
|
||||
// Scanned and pasted links skip this: they call handleSignInLink directly.
|
||||
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null);
|
||||
// The dialog answers after later renders; run the current callbacks.
|
||||
const latestRef = React.useRef({ handleSignInLink, onCancel });
|
||||
React.useEffect(() => {
|
||||
latestRef.current = { handleSignInLink, onCancel };
|
||||
}, [handleSignInLink, onCancel]);
|
||||
const mountedRef = React.useRef(false);
|
||||
const confirmingLinkIdRef = React.useRef<number | null>(null);
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
// Gone while the dialog is up: forget its link, so a late Continue runs
|
||||
// nothing and later links are not dropped as "dialog open". Checked a
|
||||
// tick later, as a StrictMode rehearsal remounts straight away.
|
||||
setTimeout(() => {
|
||||
const id = confirmingLinkIdRef.current;
|
||||
if (!mountedRef.current && id !== null) usePendingSignInLinkStore.getState().discard(id);
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!signInLinkPending || busy || searching) return;
|
||||
const taken = usePendingSignInLinkStore.getState().take();
|
||||
if (!taken) return;
|
||||
if (!taken.needsConfirmation) {
|
||||
void handleSignInLink(taken.payload);
|
||||
return;
|
||||
}
|
||||
// Add account opened for this link closes again on Cancel; one the user
|
||||
// was already working through stays where it was.
|
||||
const leaveOnCancel = isAddMode && history.length === 0;
|
||||
confirmingLinkIdRef.current = taken.id;
|
||||
let answered = false;
|
||||
const answer = (proceed: boolean) => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
confirmingLinkIdRef.current = null;
|
||||
const store = usePendingSignInLinkStore.getState();
|
||||
if (!mountedRef.current) {
|
||||
store.discard(taken.id);
|
||||
return;
|
||||
}
|
||||
if (proceed) {
|
||||
// Only the link this dialog named, and only if it is still waiting.
|
||||
const payload = store.confirm(taken.id);
|
||||
if (payload) void latestRef.current.handleSignInLink(payload);
|
||||
return;
|
||||
}
|
||||
if (store.discard(taken.id) && leaveOnCancel) latestRef.current.onCancel?.();
|
||||
};
|
||||
const { title, message } = signInLinkConfirmation(taken.payload, isAddMode, t);
|
||||
Alert.alert(
|
||||
title,
|
||||
message,
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) },
|
||||
{ text: t('login.mobile.continue', 'Continue'), onPress: () => answer(true) },
|
||||
],
|
||||
// Android back or a tap outside the dialog counts as Cancel.
|
||||
{ cancelable: true, onDismiss: () => answer(false) },
|
||||
);
|
||||
}, [busy, handleSignInLink, history.length, isAddMode, searching, signInLinkPending, t]);
|
||||
|
||||
// A tapped link that is refused outright (a pairing code for a plain-http
|
||||
// webmail) ran nothing; say why, as for a scanned one.
|
||||
const signInLinkRefused = usePendingSignInLinkStore((s) => s.refusal !== null);
|
||||
React.useEffect(() => {
|
||||
if (!signInLinkRefused || busy || searching) return;
|
||||
const refusal = usePendingSignInLinkStore.getState().takeRefusal();
|
||||
if (refusal) setNotice(describeLoginError(refusal, { t }));
|
||||
}, [busy, searching, signInLinkRefused, t]);
|
||||
|
||||
// ── render ─────────────────────────────────────────────
|
||||
|
||||
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 +508,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 +523,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 +559,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
||||
}}
|
||||
onSubmit={handleServerContinue}
|
||||
onScan={() => setScannerVisible(true)}
|
||||
onPaste={() => setPasteVisible(true)}
|
||||
failedDomain={failedDomain}
|
||||
notice={notice}
|
||||
/>
|
||||
@@ -442,11 +604,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// A full account registry (MAX_ACCOUNTS): adding an account is refused before
|
||||
// a pairing code is spent or a browser sign-in starts, and a sign-in that
|
||||
// finds the registry full only once it has connected hands the live account
|
||||
// its connection back instead of leaving the shared client signed in as the
|
||||
// new account.
|
||||
|
||||
const session = { apiUrl: 'https://mail.example.com/jmap/' };
|
||||
|
||||
const request = vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
|
||||
methodResponses: calls.map(([name, , id]) => {
|
||||
if (name === 'Identity/get') return [name, { list: [{ id: 'i1', name: 'Ada', email: 'ada@example.com' }] }, id];
|
||||
if (name === 'Mailbox/get') return [name, { list: [{ id: 'mb-in', name: 'Inbox', role: 'inbox' }], state: 'mbs-1' }, id];
|
||||
return ['error', { type: 'forbidden' }, id];
|
||||
}),
|
||||
}));
|
||||
|
||||
const LIVE_SNAPSHOT = { session: 'live-session', credentials: 'live-credentials', accountId: 'live-jmap' };
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
connect: vi.fn(async () => ({ apiUrl: 'https://mail.example.com/jmap/' })),
|
||||
connectWithOAuth: vi.fn(async () => ({
|
||||
session: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
username: 'ada@example.com',
|
||||
accountId: 'ada@example.com@mail.example.com',
|
||||
})),
|
||||
snapshot: vi.fn(() => LIVE_SNAPSHOT),
|
||||
restoreSnapshot: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
clearAccountCredentials: vi.fn(async () => undefined),
|
||||
onAuthFailure: vi.fn(() => () => undefined),
|
||||
onTokenRefresh: vi.fn(() => () => undefined),
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getAccountName: () => undefined,
|
||||
getSharedMailAccounts: () => [],
|
||||
request: (calls: Array<[string, Record<string, unknown>, string]>) => request(calls),
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
username: 'ada@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
},
|
||||
AuthenticationError: class AuthenticationError extends Error {},
|
||||
NetworkError: class NetworkError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/oauth', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../lib/oauth')>()),
|
||||
redeemPairingCode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/oauth-native', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../lib/oauth-native')>()),
|
||||
discoverOAuthMetadata: vi.fn(),
|
||||
loginWithPkce: vi.fn(),
|
||||
probeWebmail: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { redeemPairingCode, type HandoffResult } from '../../lib/oauth';
|
||||
import { discoverOAuthMetadata, loginWithPkce, probeWebmail } from '../../lib/oauth-native';
|
||||
import { AccountLimitError, MAX_ACCOUNTS } from '../../lib/account-utils';
|
||||
import { useAccountStore, type AccountEntry } from '../account-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
|
||||
const WEBMAIL = 'https://webmail.example.org/mail';
|
||||
const SERVER = 'https://mail.example.com';
|
||||
const NEW_ID = 'ada@example.com@mail.example.com';
|
||||
const redeem = redeemPairingCode as ReturnType<typeof vi.fn>;
|
||||
const connect = jmapClient.connect as unknown as ReturnType<typeof vi.fn>;
|
||||
const connectWithOAuth = jmapClient.connectWithOAuth as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const oauthResult: HandoffResult = {
|
||||
flow: 'oauth',
|
||||
serverUrl: SERVER,
|
||||
tokens: {
|
||||
accessToken: 'at',
|
||||
refreshToken: 'sealed-refresh',
|
||||
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
|
||||
clientId: 'bulwark-webmail',
|
||||
source: 'pairing',
|
||||
},
|
||||
};
|
||||
|
||||
let codeCounter = 0;
|
||||
function newCode(): string {
|
||||
codeCounter += 1;
|
||||
return `ffff${codeCounter.toString(16).padStart(60, '0')}`;
|
||||
}
|
||||
|
||||
function entry(i: number): AccountEntry {
|
||||
return {
|
||||
id: `user${i}@mail${i}.example.net`,
|
||||
serverUrl: `https://mail${i}.example.net`,
|
||||
username: `user${i}`,
|
||||
displayName: `User ${i}`,
|
||||
email: `user${i}@example.net`,
|
||||
avatarColor: '#336699',
|
||||
lastLoginAt: 0,
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: i === 0,
|
||||
};
|
||||
}
|
||||
|
||||
// `count` accounts, the first one live.
|
||||
function registry(count: number, extra: AccountEntry[] = []): void {
|
||||
const accounts = [...Array.from({ length: count - extra.length }, (_, i) => entry(i)), ...extra];
|
||||
useAccountStore.setState({ accounts, activeAccountId: accounts[0].id, defaultAccountId: accounts[0].id });
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
session: session as never,
|
||||
activeAccountId: accounts[0].id,
|
||||
username: accounts[0].username,
|
||||
serverUrl: accounts[0].serverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// What a sign-in finishing elsewhere meanwhile does: takes the last free slot.
|
||||
function fillLastSlotDuring<T>(result: T): () => Promise<T> {
|
||||
return async () => {
|
||||
const { accounts } = useAccountStore.getState();
|
||||
useAccountStore.setState({ accounts: [...accounts, entry(99)] });
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(probeWebmail as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
(discoverOAuthMetadata as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('a full registry refuses before anything is spent', () => {
|
||||
it('keeps a pairing code unredeemed when adding an account', async () => {
|
||||
registry(MAX_ACCOUNTS);
|
||||
const code = newCode();
|
||||
|
||||
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, code, { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
expect(redeem).not.toHaveBeenCalled();
|
||||
expect(jmapClient.snapshot).not.toHaveBeenCalled();
|
||||
expect(connectWithOAuth).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
activeAccountId: entry(0).id,
|
||||
error: `Maximum of ${MAX_ACCOUNTS} accounts reached`,
|
||||
});
|
||||
|
||||
// Not spent: once there is room, the same code still works.
|
||||
registry(MAX_ACCOUNTS - 1);
|
||||
redeem.mockResolvedValueOnce(oauthResult);
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, code, { addAccount: true });
|
||||
expect(redeem).toHaveBeenCalledWith(WEBMAIL, code);
|
||||
expect(useAccountStore.getState().accounts).toHaveLength(MAX_ACCOUNTS);
|
||||
expect(useAuthStore.getState().activeAccountId).toBe(NEW_ID);
|
||||
});
|
||||
|
||||
it('does not open the webmail or OAuth sign-in when adding an account', async () => {
|
||||
registry(MAX_ACCOUNTS);
|
||||
|
||||
await expect(useAuthStore.getState().loginViaWebmail(SERVER, { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
await expect(useAuthStore.getState().loginViaOAuth(SERVER, { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
expect(probeWebmail).not.toHaveBeenCalled();
|
||||
expect(loginWithPkce).not.toHaveBeenCalled();
|
||||
expect(WebBrowser.openAuthSessionAsync).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, activeAccountId: entry(0).id });
|
||||
});
|
||||
|
||||
it('refuses a password sign-in for a new account before connecting', async () => {
|
||||
registry(MAX_ACCOUNTS);
|
||||
|
||||
await expect(useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
expect(jmapClient.restoreSnapshot).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, activeAccountId: entry(0).id });
|
||||
});
|
||||
|
||||
it('still signs in again to an account it already holds', async () => {
|
||||
const ada = { ...entry(5), id: NEW_ID, serverUrl: SERVER, username: 'ada@example.com' };
|
||||
registry(MAX_ACCOUNTS, [ada]);
|
||||
|
||||
await useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true });
|
||||
expect(connect).toHaveBeenCalled();
|
||||
expect(useAccountStore.getState().accounts).toHaveLength(MAX_ACCOUNTS);
|
||||
expect(useAuthStore.getState().activeAccountId).toBe(NEW_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a registry that fills up while connecting', () => {
|
||||
it('gives the live account its connection back after an OAuth sign-in (pairing)', async () => {
|
||||
registry(MAX_ACCOUNTS - 1);
|
||||
redeem.mockResolvedValueOnce(oauthResult);
|
||||
connectWithOAuth.mockImplementationOnce(fillLastSlotDuring({
|
||||
session: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
username: 'ada@example.com',
|
||||
accountId: NEW_ID,
|
||||
}));
|
||||
|
||||
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
|
||||
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith(LIVE_SNAPSHOT);
|
||||
// The new account's stored credentials don't outlive it.
|
||||
expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith(NEW_ID);
|
||||
expect(useAccountStore.getState().accounts.map((a) => a.id)).not.toContain(NEW_ID);
|
||||
expect(useAccountStore.getState().activeAccountId).toBe(entry(0).id);
|
||||
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, isLoading: false, activeAccountId: entry(0).id });
|
||||
});
|
||||
|
||||
it('gives the live account its connection back after a password sign-in', async () => {
|
||||
registry(MAX_ACCOUNTS - 1);
|
||||
connect.mockImplementationOnce(fillLastSlotDuring({ apiUrl: 'https://mail.example.com/jmap/' }));
|
||||
|
||||
await expect(useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
|
||||
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith(LIVE_SNAPSHOT);
|
||||
expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith(NEW_ID);
|
||||
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, isLoading: false, activeAccountId: entry(0).id });
|
||||
expect(useAuthStore.getState().error).toBe(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
||||
});
|
||||
|
||||
it('keeps the credentials of an account it already held', async () => {
|
||||
const ada = { ...entry(5), id: NEW_ID, serverUrl: SERVER, username: 'ada@example.com' };
|
||||
registry(MAX_ACCOUNTS - 1, [ada]);
|
||||
// Held, so the update fits; make registering fail another way.
|
||||
const addAccount = useAccountStore.getState().addAccount;
|
||||
useAccountStore.setState({ addAccount: () => { throw new AccountLimitError(); } });
|
||||
try {
|
||||
await expect(useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { addAccount: true }))
|
||||
.rejects.toBeInstanceOf(AccountLimitError);
|
||||
} finally {
|
||||
useAccountStore.setState({ addAccount });
|
||||
}
|
||||
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith(LIVE_SNAPSHOT);
|
||||
expect(jmapClient.clearAccountCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Signing in with a code from the webmail's Link Mobile App: both answers the
|
||||
// webmail can give (an OAuth grant, or an app password), first sign-in and
|
||||
// Add account, the one-redemption guard, and the error once the code is spent.
|
||||
|
||||
const session = { apiUrl: 'https://mail.example.com/jmap/' };
|
||||
|
||||
const request = vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
|
||||
methodResponses: calls.map(([name, , id]) => {
|
||||
if (name === 'Identity/get') return [name, { list: [{ id: 'i1', name: 'Ada', email: 'ada@example.com' }] }, id];
|
||||
if (name === 'Mailbox/get') return [name, { list: [{ id: 'mb-in', name: 'Inbox', role: 'inbox' }], state: 'mbs-1' }, id];
|
||||
return ['error', { type: 'forbidden' }, id];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
connect: vi.fn(async () => ({ apiUrl: 'https://mail.example.com/jmap/' })),
|
||||
connectWithOAuth: vi.fn(async () => ({
|
||||
session: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
username: 'ada@example.com',
|
||||
accountId: 'app-account-1',
|
||||
})),
|
||||
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
|
||||
restoreSnapshot: vi.fn(),
|
||||
onAuthFailure: vi.fn(() => () => undefined),
|
||||
onTokenRefresh: vi.fn(() => () => undefined),
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getAccountName: () => undefined,
|
||||
getSharedMailAccounts: () => [],
|
||||
request: (calls: Array<[string, Record<string, unknown>, string]>) => request(calls),
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
username: 'ada@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
},
|
||||
AuthenticationError: class AuthenticationError extends Error {},
|
||||
NetworkError: class NetworkError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/oauth', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../lib/oauth')>()),
|
||||
redeemPairingCode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
|
||||
import { PairingError, redeemPairingCode, type HandoffResult } from '../../lib/oauth';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
|
||||
const WEBMAIL = 'https://webmail.example.org/mail';
|
||||
const SERVER = 'https://mail.example.com';
|
||||
const redeem = redeemPairingCode as ReturnType<typeof vi.fn>;
|
||||
const connect = jmapClient.connect as unknown as ReturnType<typeof vi.fn>;
|
||||
const connectWithOAuth = jmapClient.connectWithOAuth as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
let codeCounter = 0;
|
||||
// Every test uses fresh codes: the redeemed-code guard lives for the process.
|
||||
function newCode(): string {
|
||||
codeCounter += 1;
|
||||
return codeCounter.toString(16).padStart(64, '0');
|
||||
}
|
||||
|
||||
const oauthResult: HandoffResult = {
|
||||
flow: 'oauth',
|
||||
serverUrl: SERVER,
|
||||
tokens: {
|
||||
accessToken: 'at',
|
||||
refreshToken: 'sealed-refresh',
|
||||
tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`,
|
||||
clientId: 'bulwark-webmail',
|
||||
source: 'pairing',
|
||||
},
|
||||
};
|
||||
|
||||
const passwordResult: HandoffResult = {
|
||||
flow: 'password',
|
||||
serverUrl: SERVER,
|
||||
username: 'ada@example.com',
|
||||
password: 'app-pass-123',
|
||||
};
|
||||
|
||||
function signedOut() {
|
||||
useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null, session: null });
|
||||
}
|
||||
|
||||
function signedIn() {
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, error: null, session: session as never });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
signedOut();
|
||||
});
|
||||
|
||||
describe('loginViaPairing with an OAuth grant', () => {
|
||||
it('signs in with the redeemed tokens', async () => {
|
||||
redeem.mockResolvedValueOnce(oauthResult);
|
||||
const code = newCode();
|
||||
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, code);
|
||||
|
||||
expect(redeem).toHaveBeenCalledWith(WEBMAIL, code);
|
||||
expect(connectWithOAuth).toHaveBeenCalledWith(SERVER, oauthResult.flow === 'oauth' ? oauthResult.tokens : null);
|
||||
expect(jmapClient.snapshot).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, isLoading: false, error: null, serverUrl: SERVER });
|
||||
});
|
||||
|
||||
it('adds an account next to the signed-in one', async () => {
|
||||
signedIn();
|
||||
redeem.mockResolvedValueOnce(oauthResult);
|
||||
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true });
|
||||
|
||||
// The live connection is kept until the new one works.
|
||||
expect(jmapClient.snapshot).toHaveBeenCalled();
|
||||
expect(connectWithOAuth).toHaveBeenCalled();
|
||||
expect(jmapClient.restoreSnapshot).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginViaPairing with an app password', () => {
|
||||
it('signs in through the password login', async () => {
|
||||
redeem.mockResolvedValueOnce(passwordResult);
|
||||
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
|
||||
|
||||
expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined);
|
||||
expect(connectWithOAuth).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER });
|
||||
});
|
||||
|
||||
it('passes add-account mode on to login', async () => {
|
||||
signedIn();
|
||||
redeem.mockResolvedValueOnce(passwordResult);
|
||||
const original = useAuthStore.getState().login;
|
||||
const login = vi.fn(async () => undefined);
|
||||
useAuthStore.setState({ login });
|
||||
try {
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true });
|
||||
expect(login).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: true });
|
||||
|
||||
redeem.mockResolvedValueOnce(passwordResult);
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
|
||||
expect(login).toHaveBeenLastCalledWith(SERVER, 'ada@example.com', 'app-pass-123', { addAccount: undefined });
|
||||
} finally {
|
||||
useAuthStore.setState({ login: original });
|
||||
}
|
||||
});
|
||||
|
||||
it('adds an account through the real login, keeping the live one on failure', async () => {
|
||||
signedIn();
|
||||
redeem.mockResolvedValueOnce(passwordResult);
|
||||
connect.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
|
||||
|
||||
const err = await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true })
|
||||
.then(() => null, (e: unknown) => e);
|
||||
|
||||
expect(jmapClient.snapshot).toHaveBeenCalled();
|
||||
expect(jmapClient.restoreSnapshot).toHaveBeenCalled();
|
||||
expect(err).toBeInstanceOf(PairingError);
|
||||
expect(err).toMatchObject({ reason: 'connect_failed', host: 'mail.example.com' });
|
||||
});
|
||||
|
||||
it('leaves a second-factor prompt to the login screen', async () => {
|
||||
redeem.mockResolvedValueOnce(passwordResult);
|
||||
const totp = new Error('TOTP required');
|
||||
totp.name = 'TotpRequiredError';
|
||||
connect.mockRejectedValueOnce(totp);
|
||||
|
||||
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, newCode())).rejects.toBe(totp);
|
||||
expect(useAuthStore.getState().pendingTotpLogin).toEqual({
|
||||
serverUrl: SERVER, username: 'ada@example.com', password: 'app-pass-123',
|
||||
});
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginViaPairing guards', () => {
|
||||
it('redeems a code once, even when it arrives twice', async () => {
|
||||
let resolve!: (value: HandoffResult) => void;
|
||||
redeem.mockImplementationOnce(() => new Promise<HandoffResult>((r) => { resolve = r; }));
|
||||
const code = newCode();
|
||||
|
||||
const first = useAuthStore.getState().loginViaPairing(WEBMAIL, code);
|
||||
// Delivered again while the first is still on its way (a re-read launch URL).
|
||||
const second = await useAuthStore.getState().loginViaPairing(WEBMAIL, code).then(() => null, (e: unknown) => e);
|
||||
expect(second).toBeInstanceOf(PairingError);
|
||||
expect(second).toMatchObject({ reason: 'used', host: 'webmail.example.org' });
|
||||
|
||||
resolve(oauthResult);
|
||||
await first;
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
|
||||
// And after it went through.
|
||||
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, code)).rejects.toMatchObject({ reason: 'used' });
|
||||
expect(redeem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets a code be tried again when redeeming it failed', async () => {
|
||||
const code = newCode();
|
||||
redeem.mockRejectedValueOnce(new PairingError('network', 'Could not reach webmail.example.org', { host: 'webmail.example.org' }));
|
||||
await expect(useAuthStore.getState().loginViaPairing(WEBMAIL, code)).rejects.toMatchObject({ reason: 'network' });
|
||||
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, error: 'Could not reach webmail.example.org' });
|
||||
|
||||
redeem.mockResolvedValueOnce(oauthResult);
|
||||
await useAuthStore.getState().loginViaPairing(WEBMAIL, code);
|
||||
expect(redeem).toHaveBeenCalledTimes(2);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('says the code was used but connecting failed, naming the mail server', async () => {
|
||||
signedIn();
|
||||
redeem.mockResolvedValueOnce(oauthResult);
|
||||
connectWithOAuth.mockRejectedValueOnce(new AuthenticationError('401'));
|
||||
|
||||
const err = await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode(), { addAccount: true })
|
||||
.then(() => null, (e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PairingError);
|
||||
expect(err).toMatchObject({ reason: 'connect_failed', host: 'mail.example.com' });
|
||||
expect((err as PairingError).cause).toBeInstanceOf(AuthenticationError);
|
||||
expect(jmapClient.restoreSnapshot).toHaveBeenCalled();
|
||||
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, isAuthenticated: true });
|
||||
expect(useAuthStore.getState().error).toMatch(/Authentication rejected by server/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { createPersistStorage } from './persist-storage';
|
||||
import { generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
|
||||
import { AccountLimitError, generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
|
||||
import { generateAvatarColor } from '../lib/avatar-utils';
|
||||
|
||||
export interface AccountEntry {
|
||||
@@ -72,7 +72,7 @@ export const useAccountStore = create<AccountState>()(
|
||||
}
|
||||
|
||||
if (state.accounts.length >= MAX_ACCOUNTS) {
|
||||
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
||||
throw new AccountLimitError();
|
||||
}
|
||||
|
||||
const isDefault = state.accounts.length === 0;
|
||||
|
||||
+150
-42
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
||||
import type { JMAPSession } from '../api/types';
|
||||
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
||||
import { useAccountStore } from './account-store';
|
||||
@@ -12,8 +12,15 @@ import { flushPersistedWrites } from './persist-storage';
|
||||
import { clearEmailDetailCache } from '../lib/email-detail-cache';
|
||||
import { clearBodyDocuments } from '../lib/email-body-document';
|
||||
import { clearBodyHeights } from '../lib/body-heights';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, HandoffError, type HandoffResult } from '../lib/oauth';
|
||||
import { AccountLimitError, generateAccountId, MAX_ACCOUNTS } from '../lib/account-utils';
|
||||
import {
|
||||
runWebmailHandoff,
|
||||
redeemPairingCode,
|
||||
HandoffCancelledError,
|
||||
HandoffError,
|
||||
PairingError,
|
||||
type HandoffResult,
|
||||
} from '../lib/oauth';
|
||||
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
@@ -153,8 +160,51 @@ function refetchFeatureStores(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing codes this app process has redeemed or is redeeming. Codes are
|
||||
// single-use, so a second attempt with the same one can only fail.
|
||||
const pairingCodesSeen = new Set<string>();
|
||||
|
||||
function hostOfUrl(url: string): string {
|
||||
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/?#]/)[0];
|
||||
}
|
||||
|
||||
// Room in the registry for the account a sign-in is about to add. One it
|
||||
// already holds is an update and always fits; without an id (a code not yet
|
||||
// redeemed, a browser sign-in not yet back) a full registry means no.
|
||||
function assertRoomForAccount(accountId?: string): void {
|
||||
const { accounts } = useAccountStore.getState();
|
||||
if (accountId && accounts.some((a) => a.id === accountId)) return;
|
||||
if (accounts.length >= MAX_ACCOUNTS) throw new AccountLimitError();
|
||||
}
|
||||
|
||||
// Adding an account to a full registry: refuse before a sign-in code is
|
||||
// spent or a browser sign-in started, since what they buy can't be kept.
|
||||
function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: { addAccount?: boolean }): void {
|
||||
if (!opts?.addAccount) return;
|
||||
try {
|
||||
assertRoomForAccount();
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// connect() points the shared client at the new account and stores its
|
||||
// credentials before the registry has taken it. When registering it fails
|
||||
// (the account limit), undo both: the live account, when one is being added
|
||||
// to, gets the client back, and an account the registry never took keeps no
|
||||
// credentials behind. Otherwise every request would go out as the new
|
||||
// account while the app still shows the old one.
|
||||
async function undoConnect(previous: ClientSnapshot | null, accountId: string, wasRegistered: boolean): Promise<void> {
|
||||
if (previous) jmapClient.restoreSnapshot(previous);
|
||||
else jmapClient.reset();
|
||||
if (!wasRegistered) await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Best-effort RFC 7009 revocation of an account's refresh token on sign-out.
|
||||
// A QR-paired phone shares the desktop's token, so those are left alone.
|
||||
// QR-paired bundles are left alone: webmail up to 1.11 handed the phone the
|
||||
// desktop's own refresh token, and newer ones hand out a separate grant whose
|
||||
// refresh token only the webmail's token proxy understands.
|
||||
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
|
||||
try {
|
||||
const entry = useAccountStore.getState().getAccountById(accountId);
|
||||
@@ -222,21 +272,29 @@ async function completeOAuthHandoff(
|
||||
throw err;
|
||||
}
|
||||
const { session, username, accountId } = connected;
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
const wasRegistered = !!accountStore.getAccountById(accountId);
|
||||
try {
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
} catch (err) {
|
||||
await undoConnect(previous, accountId, wasRegistered);
|
||||
throw err;
|
||||
}
|
||||
// Contacts/calendar are still single-bucket, so wipe those now that the
|
||||
// new account is registered and the one the client serves.
|
||||
if (previous) {
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
@@ -286,7 +344,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// Adding an additional account: keep the live connection until the new
|
||||
// sign-in succeeded so a typo doesn't kill the current session.
|
||||
const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null;
|
||||
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
|
||||
try {
|
||||
// A new account with no room left fails here, before connect swaps
|
||||
// the live client over or stores credentials for it.
|
||||
assertRoomForAccount(accountId);
|
||||
const wasRegistered = !!useAccountStore.getState().getAccountById(accountId);
|
||||
let session: JMAPSession;
|
||||
try {
|
||||
session = await jmapClient.connect(serverUrl, username, password, opts?.totp);
|
||||
@@ -294,24 +357,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (previous) jmapClient.restoreSnapshot(previous);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
try {
|
||||
accountStore.addAccount({
|
||||
serverUrl: serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
} catch (err) {
|
||||
await undoConnect(previous, accountId, wasRegistered);
|
||||
throw err;
|
||||
}
|
||||
// Contacts/calendar are still single-bucket, so wipe those now that
|
||||
// the new account is the one the client serves.
|
||||
// the new account is registered and the one the client serves.
|
||||
if (previous) {
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
// Swap the email store's active view to the new account so the rest of
|
||||
// this function (and refetchFeatureStores) writes to the right bucket.
|
||||
@@ -343,6 +410,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
loginViaWebmail: async (webmailUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
refuseAddWhenFull(set, opts);
|
||||
// Discovery finds the *JMAP* host; a Bulwark webmail is not necessarily
|
||||
// served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart
|
||||
// lands on a 404 or the admin page, so check first and fall back to the
|
||||
@@ -398,6 +466,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
loginViaOAuth: async (serverUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
refuseAddWhenFull(set, opts);
|
||||
const base = serverUrl.replace(/\/+$/, '');
|
||||
let tokens;
|
||||
try {
|
||||
@@ -428,34 +497,73 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
loginViaPairing: async (webmailUrl, code, opts) => {
|
||||
// A code is good for one redemption. The same link can reach us twice
|
||||
// (a deep link re-read on launch, a double tap, a scan racing a paste);
|
||||
// the second attempt must not spend a request only to be told "used".
|
||||
if (pairingCodesSeen.has(code)) {
|
||||
throw new PairingError('used', 'This pairing code was already used on this device', {
|
||||
host: hostOfUrl(webmailUrl),
|
||||
});
|
||||
}
|
||||
// With no room for another account the code stays unspent: redeeming it
|
||||
// would only buy a sign-in the registry can't keep.
|
||||
refuseAddWhenFull(set, opts);
|
||||
pairingCodesSeen.add(code);
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
let result;
|
||||
let result: HandoffResult;
|
||||
try {
|
||||
result = await redeemPairingCode(webmailUrl, code);
|
||||
} catch (err) {
|
||||
// Not redeemed here: the server is the judge of a retry (it answers
|
||||
// "used" if the request did get through).
|
||||
pairingCodesSeen.delete(code);
|
||||
const message = err instanceof Error ? err.message : 'Pairing failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
|
||||
// future server change can't silently mis-route credentials here.
|
||||
if (result.flow !== 'oauth') {
|
||||
set({ isLoading: false, error: 'Unexpected pairing response' });
|
||||
throw new Error('Unexpected pairing response');
|
||||
// The code is spent from here on. A failure to sign in with what it
|
||||
// bought needs a new code, which the error says. Two failures keep their
|
||||
// own error: a second factor the login screen can still ask for (servers
|
||||
// without app passwords hand over the account password, which `login`
|
||||
// keeps for that step), and the account limit, which a new code won't fix.
|
||||
const serverHost = hostOfUrl(result.serverUrl);
|
||||
const connectFailed = (err: unknown): unknown => {
|
||||
if (err instanceof Error && (err.name === 'TotpRequiredError' || err instanceof AccountLimitError)) {
|
||||
// `login` has settled the store already; the OAuth path has not.
|
||||
if (get().isLoading) set({ isLoading: false, error: err.message });
|
||||
return err;
|
||||
}
|
||||
const message = err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Pairing sign-in failed';
|
||||
const wrapped = new PairingError('connect_failed', `Signed in, but connecting failed: ${message}`, {
|
||||
host: serverHost,
|
||||
cause: err,
|
||||
});
|
||||
set({ isLoading: false, error: wrapped.message });
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
if (result.flow === 'password') {
|
||||
// An app password (or, for servers without them, the account
|
||||
// password): the normal password sign-in, which also keeps the live
|
||||
// account when adding another one fails.
|
||||
try {
|
||||
await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount });
|
||||
} catch (err) {
|
||||
throw connectFailed(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completeOAuthHandoff(set, get, result, opts);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Pairing sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
throw connectFailed(err);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -37,12 +37,16 @@ function query(params: Record<string, string | undefined>): string {
|
||||
return parts.length ? `?${parts.join('&')}` : '';
|
||||
}
|
||||
|
||||
// Message links carry the thread so the app opens them without asking the
|
||||
// server first, which also works offline from its cache.
|
||||
type MessageRef = Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'> & { threadId?: string };
|
||||
|
||||
export const links = {
|
||||
inbox: () => `${base}mail/folder/inbox`,
|
||||
message: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
|
||||
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
|
||||
reply: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
|
||||
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, action: 'reply' })}`,
|
||||
message: (m: MessageRef) =>
|
||||
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId })}`,
|
||||
reply: (m: MessageRef) =>
|
||||
`${base}mail/message/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId, thread: m.threadId, action: 'reply' })}`,
|
||||
draft: (m: Pick<MailItem, 'id' | 'accountId' | 'jmapAccountId'>) =>
|
||||
`${base}mail/draft/${enc(m.id)}${query({ account: m.accountId, jmapAccount: m.jmapAccountId })}`,
|
||||
unified: (params: { view?: 'all' | 'unread' | 'starred'; role?: string } = {}) =>
|
||||
|
||||
+2
-1
@@ -184,7 +184,7 @@ export async function fetchMailSection(
|
||||
calls.push(['Email/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: 'qa', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'from', 'receivedAt', 'attachments'],
|
||||
properties: ['id', 'threadId', 'from', 'receivedAt', 'attachments'],
|
||||
bodyProperties: ['name', 'type', 'disposition', 'cid', 'size'],
|
||||
}, 'ga']);
|
||||
}
|
||||
@@ -221,6 +221,7 @@ export async function fetchMailSection(
|
||||
}>)
|
||||
.map((e) => ({
|
||||
emailId: e.id,
|
||||
threadId: e.threadId,
|
||||
accountId: registryAccountId,
|
||||
fromName: e.from?.[0]?.name || e.from?.[0]?.email || '',
|
||||
receivedAt: Date.parse(e.receivedAt) || 0,
|
||||
|
||||
@@ -29,8 +29,6 @@ import type { EventItem, WidgetSnapshot } from '../snapshot';
|
||||
import { chipBackground, normalizeHex, type WidgetPalette } from '../theme';
|
||||
import type { Layout } from './types';
|
||||
|
||||
/** Surface's 1dp frame; Android adds a border to the padding, so it eats space. */
|
||||
const FRAME = 1;
|
||||
const HOUR = 3600000;
|
||||
const GRID_START_HOUR = 8;
|
||||
const GRID_END_HOUR = 18;
|
||||
@@ -207,7 +205,7 @@ export const AgendaLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
);
|
||||
}
|
||||
// Header bar plus its 1dp rule.
|
||||
const room = height - 2 * FRAME - headerHeight - 1;
|
||||
const room = height - headerHeight - 1;
|
||||
const { sections, hidden } = planAgenda(days, room);
|
||||
const rows: React.JSX.Element[] = [];
|
||||
sections.forEach((day, d) => {
|
||||
@@ -366,12 +364,12 @@ export const WeekLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
const events = s.calendar.events;
|
||||
const first = startOfWeek(now, s.weekStart);
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(first, i));
|
||||
const colWidth = (width - 2 * FRAME - 2 * WEEK_PAD) / 7;
|
||||
const colWidth = (width - 2 * WEEK_PAD) / 7;
|
||||
const segments = allDaySegments(events, days);
|
||||
const strip = segments.some((seg) => seg.event !== null);
|
||||
const gridHeight = Math.max(
|
||||
0,
|
||||
height - 2 * FRAME - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
|
||||
height - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
|
||||
- (strip ? WEEK_STRIP_HEIGHT + WEEK_GAP : 0) - 1,
|
||||
);
|
||||
const windowStart = startOfDay(now) + GRID_START_HOUR * HOUR;
|
||||
@@ -566,11 +564,11 @@ export const MonthLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
const weeks = monthGrid(s.calendar.events, now, s.weekStart);
|
||||
const next = upcoming(s.calendar.events, now);
|
||||
const headerHeight = height >= 340 ? 44 : 38;
|
||||
const cellWidth = (width - 2 * FRAME - 2 * MONTH_PAD) / 7;
|
||||
const cellWidth = (width - 2 * MONTH_PAD) / 7;
|
||||
const chipsHeight = (n: number) =>
|
||||
n === 0 ? 0 : MONTH_CHIPS_TOP + n * MONTH_CHIP_HEIGHT + (n - 1) * 4 + MONTH_CHIPS_BOTTOM;
|
||||
const gridRoom = (n: number) =>
|
||||
height - 2 * FRAME - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
|
||||
height - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
|
||||
// Two chips when the weeks still get a comfortable row, else one, else none.
|
||||
let chips = Math.min(2, next.length);
|
||||
while (chips > 0 && gridRoom(chips) / weeks.length < MONTH_CELL_MIN) chips -= 1;
|
||||
@@ -700,7 +698,7 @@ function TodayColumn({
|
||||
const tomorrowList = eventsOnDay(events, addDays(today, 1));
|
||||
|
||||
// The "N more this week" line is always reserved; chips share the rest.
|
||||
const room = height - 2 * FRAME - 2 * TODAY_PAD - TODAY_LINE;
|
||||
const room = height - 2 * TODAY_PAD - TODAY_LINE;
|
||||
const row = TODAY_CHIP + TODAY_GAP;
|
||||
let withTomorrow = tomorrowList.length > 0;
|
||||
let slots = Math.floor((room - (withTomorrow ? TOMORROW_BLOCK : 0) + TODAY_GAP) / row);
|
||||
@@ -792,7 +790,7 @@ function TodayColumn({
|
||||
export const TodayEventsLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
// The date block is ~108dp tall at 40sp; a short 4x2 gets a smaller number
|
||||
// and, if still tight, loses the month line.
|
||||
const room = height - 2 * FRAME - 2 * TODAY_PAD;
|
||||
const room = height - 2 * TODAY_PAD;
|
||||
const big = room >= 108 ? 40 : 32;
|
||||
const showMonth = room >= 3 * 18 + Math.ceil(big * 1.33);
|
||||
return (
|
||||
@@ -820,7 +818,7 @@ export const NextEventLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
const when = running ? f.t('widgets.calendar.now', 'Now') : capitalize(f.relative(e.start, now), f.locale);
|
||||
// Status line, gap, one title line and the time range always show; then the
|
||||
// place, the calendar and a second title line, as long as they fit.
|
||||
const room = height - 2 * FRAME - 28;
|
||||
const room = height - 28;
|
||||
let used = 16 + 6 + 22 + 18;
|
||||
const place = placeOf(f, e);
|
||||
const showPlace = !!place && used + 16 <= room;
|
||||
@@ -888,7 +886,7 @@ function TwoLineChip({ f, e, now }: { f: Fmt; e: EventItem; now: number }) {
|
||||
}
|
||||
|
||||
export const DateNextLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
const room = height - 2 * FRAME - 2 * DATE_PAD - DATE_CIRCLE - 8;
|
||||
const room = height - 2 * DATE_PAD - DATE_CIRCLE - 8;
|
||||
const count = room >= 2 * DATE_CHIP + 4 ? 2 : 1;
|
||||
const next = s.calendar.supported ? upcoming(s.calendar.events, now).slice(0, count) : [];
|
||||
const chips: React.JSX.Element[] = [];
|
||||
|
||||
@@ -306,8 +306,8 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
height: 'match_parent',
|
||||
padding: pad,
|
||||
backgroundColor: unread > 0 ? p.unreadRow : p.bg,
|
||||
borderTopLeftRadius: WIDGET_RADIUS - 1,
|
||||
borderBottomLeftRadius: WIDGET_RADIUS - 1,
|
||||
borderTopLeftRadius: WIDGET_RADIUS,
|
||||
borderBottomLeftRadius: WIDGET_RADIUS,
|
||||
}}
|
||||
>
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
@@ -616,7 +616,7 @@ export const AttachmentsLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
return (
|
||||
<FlexWidget
|
||||
key={g.emailId}
|
||||
{...open(links.message({ id: g.emailId, accountId: g.accountId }))}
|
||||
{...open(links.message({ id: g.emailId, threadId: g.threadId, accountId: g.accountId }))}
|
||||
style={{
|
||||
width: 'match_parent',
|
||||
paddingHorizontal: 14,
|
||||
|
||||
@@ -17,9 +17,14 @@ import { chipBackground, normalizeHex, type WidgetPalette } from './theme';
|
||||
import { filledIcon, icon, type FilledIconName, type IconName } from './icons';
|
||||
import type { WidgetClick } from './clicks';
|
||||
|
||||
/** Corner radius of the widget surface; launchers clip to roughly this on Android 12+. */
|
||||
/** Corner radius of the widget surface; `widget_radius` in android/app/src/main/res/values/dimens.xml. */
|
||||
export const WIDGET_RADIUS = 22;
|
||||
|
||||
/**
|
||||
* The widget's background. Its edge and the clip to rounded corners come from
|
||||
* the root layout (android/app/src/main/res/layout/rn_widget.xml), which has the
|
||||
* launcher's real size; this bitmap is drawn at the reported one and can be cut off.
|
||||
*/
|
||||
export function Surface({
|
||||
p,
|
||||
children,
|
||||
@@ -39,8 +44,6 @@ export function Surface({
|
||||
width: 'match_parent',
|
||||
backgroundColor: p.bg,
|
||||
borderRadius: WIDGET_RADIUS,
|
||||
borderWidth: 1,
|
||||
borderColor: p.frame,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
|
||||
@@ -157,9 +157,9 @@ export function sampleSnapshot(now: number): WidgetSnapshot {
|
||||
recentSearches: ['from:sarah', 'invoice', 'has:attachment', 'Q4 timeline', 'is:unread'],
|
||||
tags: [{ keyword: '$label:receipts', name: 'Receipts', color: '#f97316', unread: 2, total: 14, latestFrom: 'Sarah Kim', latestAt: ago(46) }],
|
||||
attachments: [
|
||||
{ emailId: 'm2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
|
||||
{ emailId: 'm2', threadId: 't-m2', accountId: 'demo@example.com@demo', fromName: 'Sarah Kim', receivedAt: ago(46),
|
||||
files: [{ name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, { name: 'prototype-v3.png', type: 'image/png' }] },
|
||||
{ emailId: 'm7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
|
||||
{ emailId: 'm7', threadId: 't-m7', accountId: 'demo@example.com@demo', fromName: 'Anna Kowalski', receivedAt: ago(60 * 48),
|
||||
files: [{ name: 'wedding-001.jpg', type: 'image/jpeg' }, { name: 'wedding-014.jpg', type: 'image/jpeg' }, { name: 'wedding-038.jpg', type: 'image/jpeg' }] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface TagCount {
|
||||
|
||||
export interface AttachmentGroup {
|
||||
emailId: string;
|
||||
/** Absent in snapshots stored before it was kept. */
|
||||
threadId?: string;
|
||||
accountId: string;
|
||||
fromName: string;
|
||||
receivedAt: number;
|
||||
|
||||
@@ -10,15 +10,16 @@ export type WidgetScheme = 'light' | 'dark';
|
||||
|
||||
export interface WidgetPalette {
|
||||
scheme: WidgetScheme;
|
||||
/** Widget surface (webmail `--color-background`). */
|
||||
/**
|
||||
* Widget surface (webmail `--color-background`). The edge around it is drawn
|
||||
* natively, as `widget_frame` in android/app/src/main/res/values/colors.xml.
|
||||
*/
|
||||
bg: ColorProp;
|
||||
fg: ColorProp;
|
||||
muted: ColorProp;
|
||||
/** Search pill, segmented track, quota ring track (webmail `--color-muted`). */
|
||||
mutedBg: ColorProp;
|
||||
border: ColorProp;
|
||||
/** Outer widget edge. */
|
||||
frame: ColorProp;
|
||||
primary: ColorProp;
|
||||
primaryFg: ColorProp;
|
||||
accent: ColorProp;
|
||||
@@ -66,7 +67,6 @@ export const LIGHT: WidgetPalette = {
|
||||
muted: '#64748b',
|
||||
mutedBg: '#f1f5f9',
|
||||
border: '#e2e8f0',
|
||||
frame: '#e2e8f0',
|
||||
primary: '#3b82f6',
|
||||
primaryFg: '#ffffff',
|
||||
accent: '#dbeafe',
|
||||
@@ -108,7 +108,6 @@ export const DARK: WidgetPalette = {
|
||||
muted: '#a3a3a3',
|
||||
mutedBg: '#262626',
|
||||
border: '#2d2d2d',
|
||||
frame: 'rgba(128, 128, 128, 0.3)',
|
||||
primary: '#fafafa',
|
||||
primaryFg: '#171717',
|
||||
accent: '#1e3a8a',
|
||||
|
||||
Reference in New Issue
Block a user