mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: ask before handing a sign-in to the mobile app and only to its own callback link
This commit is contained in:
@@ -10,6 +10,8 @@ import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-o
|
||||
import { Loader2, AlertCircle } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
|
||||
function OAuthCallbackInner() {
|
||||
const router = useRouter();
|
||||
@@ -18,6 +20,9 @@ function OAuthCallbackInner() {
|
||||
const t = useTranslations("login");
|
||||
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A sign-in started for the mobile app, waiting for the user to confirm
|
||||
// the handoff (lib/auth/mobile-handoff.ts).
|
||||
const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null);
|
||||
// The authorization code is single-use. React's development double effect
|
||||
// ran this twice: the second pass no longer saw the pairing re-auth flag
|
||||
// (the first had removed it) and spent the code on the login flow instead.
|
||||
@@ -135,14 +140,14 @@ function OAuthCallbackInner() {
|
||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
|
||||
// Branch on mobile handoff first: the login page left a marker in
|
||||
// sessionStorage if it kicked this OAuth dance off for the mobile app.
|
||||
let mobileRedirectUri: string | null = null;
|
||||
let mobileRedirectUri = "";
|
||||
let mobileState: string | null = null;
|
||||
try {
|
||||
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
|
||||
mobileRedirectUri = mobileHandoffTarget(sessionStorage.getItem("mobile_redirect_uri"));
|
||||
mobileState = sessionStorage.getItem("mobile_state");
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
|
||||
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
|
||||
if (mobileRedirectUri) {
|
||||
// Drive /api/auth/sso/complete directly so we can read the tokens
|
||||
// out of the response - loginWithServerSso would consume them and
|
||||
// wire up the webmail auth store, which isn't useful here. The
|
||||
@@ -187,7 +192,12 @@ function OAuthCallbackInner() {
|
||||
sessionStorage.removeItem("mobile_redirect_uri");
|
||||
sessionStorage.removeItem("mobile_state");
|
||||
} catch { /* ignore */ }
|
||||
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||
// Shown to the user first; the navigation happens on their confirmation.
|
||||
setPendingHandoff({
|
||||
url: buildMobileHandoffUrl(mobileRedirectUri, Object.fromEntries(fragment)),
|
||||
// The complete route names the account's server, not the user.
|
||||
username: typeof data.username === "string" ? data.username : serverUrl,
|
||||
});
|
||||
} catch {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
@@ -220,6 +230,21 @@ function OAuthCallbackInner() {
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (pendingHandoff) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<MobileHandoffConsent
|
||||
url={pendingHandoff.url}
|
||||
username={pendingHandoff.username}
|
||||
onCancel={() => {
|
||||
setPendingHandoff(null);
|
||||
router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
} from "@/lib/auth/lite-oauth";
|
||||
import { toAsciiDomain } from "@/lib/idn";
|
||||
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
|
||||
import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
|
||||
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
|
||||
function completeAddressDomain(username: string): string {
|
||||
@@ -134,11 +136,6 @@ function VersionBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
// Only redirect targets matching this scheme are honored by the mobile
|
||||
// handoff path. Without the check the login page becomes an open redirector
|
||||
// that funnels password and token material to any caller-supplied URL.
|
||||
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
@@ -150,13 +147,13 @@ function LoginPageContent() {
|
||||
// these params. We grab them once at mount and stash them in a ref so any
|
||||
// login path that completes (password or OAuth) can hand control back to
|
||||
// the app instead of routing into /mail.
|
||||
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
|
||||
// Only the app's own callback link is a handoff target, and nothing is
|
||||
// handed over before the user confirms it (lib/auth/mobile-handoff.ts).
|
||||
const rawMobileState = searchParams.get("mobile_state") ?? "";
|
||||
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
|
||||
? rawMobileRedirectUri
|
||||
: "";
|
||||
const mobileRedirectUri = mobileHandoffTarget(searchParams.get("mobile_redirect_uri"));
|
||||
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null);
|
||||
usePrefetchMailView(!isMobileHandoff);
|
||||
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
@@ -374,12 +371,13 @@ function LoginPageContent() {
|
||||
// mobile app instead of pushing to /mail. handleSubmit captured the
|
||||
// values needed for the fragment.
|
||||
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
|
||||
const fragment = new URLSearchParams({
|
||||
flow: "password",
|
||||
...mobileHandoffPayloadRef.current,
|
||||
state: mobileState,
|
||||
const payload = mobileHandoffPayloadRef.current;
|
||||
mobileHandoffPayloadRef.current = null;
|
||||
// Shown to the user first; the navigation happens on their confirmation.
|
||||
setPendingHandoff({
|
||||
url: buildMobileHandoffUrl(mobileRedirectUri, { flow: "password", ...payload, state: mobileState }),
|
||||
username: payload.username,
|
||||
});
|
||||
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||
return;
|
||||
}
|
||||
let redirectTo = '/';
|
||||
@@ -1110,6 +1108,14 @@ function LoginPageContent() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
<MobileHandoffConsent
|
||||
url={pendingHandoff?.url ?? null}
|
||||
username={pendingHandoff?.username ?? ""}
|
||||
onCancel={() => {
|
||||
setPendingHandoff(null);
|
||||
router.push('/');
|
||||
}}
|
||||
/>
|
||||
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
|
||||
<div className="absolute top-5 right-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
|
||||
@@ -11,15 +11,15 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||
// this prefix may flow through the mobile handoff path; without the guard
|
||||
// the SSO complete route would be coerced into returning tokens to whatever
|
||||
// Only the mobile app's own callback link may flow through the mobile
|
||||
// handoff path (lib/auth/mobile-handoff.ts); without the guard the SSO
|
||||
// complete route would be coerced into returning tokens to whatever
|
||||
// caller-controlled URL the attacker chose.
|
||||
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||
|
||||
/**
|
||||
* A re-auth mints the grant a paired phone keeps using. Providers such as
|
||||
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
|
||||
: null;
|
||||
const mobileState =
|
||||
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
|
||||
if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, string>) =>
|
||||
values?.username ? `${key}:${values.username}` : key,
|
||||
}));
|
||||
|
||||
import { MobileHandoffConsent } from '../mobile-handoff-consent';
|
||||
|
||||
// A sign-in started with `mobile_redirect_uri` used to navigate to the app
|
||||
// link the moment it succeeded, password or tokens in the fragment. The user
|
||||
// must confirm first, and nothing navigates until they do.
|
||||
|
||||
const URL_TO_APP = 'bulwarkmobile://auth/callback#flow=password&password=secret';
|
||||
|
||||
let replaced: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
replaced = [];
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...window.location, replace: (url: string) => { replaced.push(url); } },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('MobileHandoffConsent', () => {
|
||||
it('shows what will happen and navigates only on confirmation', () => {
|
||||
render(<MobileHandoffConsent url={URL_TO_APP} username="alice@example.org" onCancel={() => {}} />);
|
||||
expect(screen.getByText('mobile_handoff.title')).toBeTruthy();
|
||||
expect(screen.getByText('mobile_handoff.description:alice@example.org')).toBeTruthy();
|
||||
expect(replaced).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByText('mobile_handoff.confirm'));
|
||||
expect(replaced).toEqual([URL_TO_APP]);
|
||||
});
|
||||
|
||||
it('keeps the user in the webmail when they decline', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<MobileHandoffConsent url={URL_TO_APP} username="alice@example.org" onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText('mobile_handoff.cancel'));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(replaced).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders nothing without a pending handoff', () => {
|
||||
render(<MobileHandoffConsent url={null} username="" onCancel={() => {}} />);
|
||||
expect(screen.queryByText('mobile_handoff.title')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
||||
interface MobileHandoffConsentProps {
|
||||
/** The app link the browser will open, with the credential material in its fragment. */
|
||||
url: string | null;
|
||||
/** The account being handed over, for the explanation. */
|
||||
username: string;
|
||||
/** The user stays in the webmail instead. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step between a sign-in that was started with `mobile_redirect_uri` and
|
||||
* the navigation that hands its password or tokens to the mobile app. The
|
||||
* page otherwise reads exactly like the ordinary sign-in page, and a link
|
||||
* carrying that parameter is all it takes to open it; whoever owns the
|
||||
* `bulwarkmobile` scheme on the device receives whatever is handed over
|
||||
* (lib/auth/mobile-handoff.ts). Nothing leaves without this confirmation.
|
||||
*/
|
||||
export function MobileHandoffConsent({ url, username, onCancel }: MobileHandoffConsentProps) {
|
||||
const t = useTranslations("login");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
isOpen={!!url}
|
||||
onClose={onCancel}
|
||||
onConfirm={() => {
|
||||
if (url) window.location.replace(url);
|
||||
}}
|
||||
title={t("mobile_handoff.title")}
|
||||
message={t("mobile_handoff.description", { username })}
|
||||
confirmText={t("mobile_handoff.confirm")}
|
||||
cancelText={t("mobile_handoff.cancel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MOBILE_HANDOFF_REDIRECT_URI, buildMobileHandoffUrl, mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
|
||||
|
||||
// The login page handed the typed password (and the SSO callback the tokens)
|
||||
// to any `bulwarkmobile://` URL named in the query string, with nothing on
|
||||
// screen to tell the page apart from a normal sign-in. The target is now the
|
||||
// app's one callback link, and the pages confirm with the user before they
|
||||
// navigate (components/auth/mobile-handoff-consent.tsx).
|
||||
|
||||
describe('mobileHandoffTarget', () => {
|
||||
it('accepts only the mobile app callback link', () => {
|
||||
expect(mobileHandoffTarget('bulwarkmobile://auth/callback')).toBe(MOBILE_HANDOFF_REDIRECT_URI);
|
||||
expect(mobileHandoffTarget(' bulwarkmobile://auth/callback/ ')).toBe(MOBILE_HANDOFF_REDIRECT_URI);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'bulwarkmobile://x',
|
||||
'bulwarkmobile://auth/callback?x=1',
|
||||
'bulwarkmobile://auth/callback#frag',
|
||||
'bulwarkmobile://evil.example/auth/callback',
|
||||
'https://evil.example/',
|
||||
'javascript:alert(1)',
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
])('refuses %s', (raw) => {
|
||||
expect(mobileHandoffTarget(raw)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMobileHandoffUrl', () => {
|
||||
it('puts the material in the fragment, encoded', () => {
|
||||
const url = buildMobileHandoffUrl(MOBILE_HANDOFF_REDIRECT_URI, { flow: 'password', password: 'p&ss#1', state: 's' });
|
||||
expect(url.startsWith('bulwarkmobile://auth/callback#')).toBe(true);
|
||||
expect(url).toContain('password=p%26ss%231');
|
||||
expect(url.split('#')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The browser-login handoff to the Bulwark mobile app.
|
||||
*
|
||||
* The app opens `/login?mobile_redirect_uri=…&mobile_state=…` in the system
|
||||
* browser; once the user has signed in, the page navigates to that URI with
|
||||
* the credential material in the fragment. Two things bound what that page
|
||||
* will do with a user's password or tokens:
|
||||
*
|
||||
* - The target is the app's one callback link, not any `bulwarkmobile://`
|
||||
* URL a query string names. A custom scheme is claimable by any app on
|
||||
* the device (RFC 8252 §8.4), so a narrower target is not proof of who
|
||||
* receives it, but it is the one the app registers and nothing else.
|
||||
* - The page never navigates on its own. It shows what is about to happen
|
||||
* and lets the user confirm (components/auth/mobile-handoff-consent.tsx):
|
||||
* a link that opens the webmail with these parameters otherwise reads
|
||||
* exactly like the normal sign-in page and hands the typed password to
|
||||
* whichever app owns the scheme.
|
||||
*/
|
||||
export const MOBILE_HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
|
||||
|
||||
/** The handoff target when `raw` is the app's callback link, else ''. */
|
||||
export function mobileHandoffTarget(raw: string | null | undefined): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === MOBILE_HANDOFF_REDIRECT_URI || trimmed === `${MOBILE_HANDOFF_REDIRECT_URI}/`
|
||||
? MOBILE_HANDOFF_REDIRECT_URI
|
||||
: '';
|
||||
}
|
||||
|
||||
/** The URL the browser hands to the app: the target plus the fragment. */
|
||||
export function buildMobileHandoffUrl(target: string, fragment: Record<string, string>): string {
|
||||
return `${target}#${new URLSearchParams(fragment).toString()}`;
|
||||
}
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "تشغيل النسخة التجريبية",
|
||||
"demo_tagline": "جرّب عميل بريد إلكتروني متكامل الميزات. لا حاجة إلى حساب.",
|
||||
"demo_no_signup": "لا حاجة للتسجيل - استكشف بحرية باستخدام بيانات نموذجية",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "جارٍ إتمام تسجيل الدخول...",
|
||||
"oauth_error": {
|
||||
"title": "فشلت المصادقة",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Inicia la demo",
|
||||
"demo_tagline": "Proveu un client de correu complet. No cal cap compte.",
|
||||
"demo_no_signup": "No cal registrar-se - exploreu lliurement amb dades d'exemple",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Completant l'inici de sessió...",
|
||||
"oauth_error": {
|
||||
"title": "Ha fallat l'autenticació",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Spustit demoverzi",
|
||||
"demo_tagline": "Vyzkoušejte plně funkční e-mailový klient. Účet není vyžadován.",
|
||||
"demo_no_signup": "Bez registrace - volně prozkoumejte s ukázkovými daty",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Dokončování přihlášení...",
|
||||
"oauth_error": {
|
||||
"title": "Ověření selhalo",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Start demo",
|
||||
"demo_tagline": "Oplev en fuldt udstyret e-mailklient. Ingen konto påkrævet.",
|
||||
"demo_no_signup": "Ingen tilmelding nødvendig - udforsk frit med eksempeldata",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Fuldfører login...",
|
||||
"oauth_error": {
|
||||
"title": "Godkendelse mislykkedes",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Demo starten",
|
||||
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
|
||||
"demo_no_signup": "Keine Registrierung nötig - erkunden Sie frei mit Beispieldaten",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
||||
"oauth_error": {
|
||||
"title": "Authentifizierung fehlgeschlagen",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Launch Demo",
|
||||
"demo_tagline": "Experience a full-featured email client. No account required.",
|
||||
"demo_no_signup": "No signup needed - explore freely with sample data",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Completing sign in...",
|
||||
"oauth_error": {
|
||||
"title": "Authentication Failed",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Iniciar demo",
|
||||
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
|
||||
"demo_no_signup": "Sin registro - explora libremente con datos de ejemplo",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Completando inicio de sesión...",
|
||||
"oauth_error": {
|
||||
"title": "Error de autenticación",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Käivita demo",
|
||||
"demo_tagline": "Proovi täisfunktsionaalset e-posti klienti. Kontot pole vaja.",
|
||||
"demo_no_signup": "Registreerimist pole vaja - tutvu vabalt näidisandmetega",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Sisselogimise lõpetamine...",
|
||||
"oauth_error": {
|
||||
"title": "Autentimine ebaõnnestus",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "راهاندازی نسخه آزمایشی",
|
||||
"demo_tagline": "تجربه یک سرویس ایمیل کامل. بدون نیاز به حساب کاربری.",
|
||||
"demo_no_signup": "نیازی به ثبتنام نیست - آزادانه با دادههای نمونه کاوش کنید",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "در حال تکمیل ورود...",
|
||||
"oauth_error": {
|
||||
"title": "احراز هویت ناموفق",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Lancer la démo",
|
||||
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
|
||||
"demo_no_signup": "Aucune inscription - explorez librement avec des données d'exemple",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Connexion en cours...",
|
||||
"oauth_error": {
|
||||
"title": "Échec de l'authentification",
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
"demo_login_button": "הפעל הדגמה",
|
||||
"demo_tagline": "התנסה בלקוח דוא\"ל מלא. אין צורך בחשבון.",
|
||||
"demo_no_signup": "אין צורך בהרשמה - חקור בחופשיות עם נתונים לדוגמה",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "משלים כניסה...",
|
||||
"oauth_error": {
|
||||
"title": "האימות נכשל",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Demó indítása",
|
||||
"demo_tagline": "Tapasztalj meg egy teljes értékű e-mail klienst. Nincs szükség fiókra.",
|
||||
"demo_no_signup": "Nincs szükség regisztrációra - fedezd fel szabadon mintaadatokkal",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Bejelentkezés befejezése...",
|
||||
"oauth_error": {
|
||||
"title": "Hitelesítés sikertelen",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Avvia demo",
|
||||
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
|
||||
"demo_no_signup": "Nessuna registrazione - esplora liberamente con dati di esempio",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Completamento dell'accesso...",
|
||||
"oauth_error": {
|
||||
"title": "Autenticazione non riuscita",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "デモを開始",
|
||||
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
|
||||
"demo_no_signup": "登録不要 - サンプルデータで自由に探索",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "サインイン処理中...",
|
||||
"oauth_error": {
|
||||
"title": "認証に失敗しました",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "데모 시작",
|
||||
"demo_tagline": "모든 기능을 갖춘 이메일 클라이언트를 경험해 보세요. 계정이 없어도 괜찮아요.",
|
||||
"demo_no_signup": "가입할 필요 없이 샘플 데이터로 자유롭게 둘러보세요",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "로그인을 완료하는 중...",
|
||||
"oauth_error": {
|
||||
"title": "인증 실패",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Palaist demo",
|
||||
"demo_tagline": "Izmēģiniet pilnvērtīgu e-pasta klientu bez konta izveides.",
|
||||
"demo_no_signup": "Reģistrācija nav nepieciešama - brīvi pētiet paraugdatus",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Pabeidz pierakstīšanos...",
|
||||
"oauth_error": {
|
||||
"title": "Autentifikācijas kļūda",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Демо нээх",
|
||||
"demo_tagline": "Бүрэн боломжтой имэйлийн клиентийг туршиж үзээрэй. Бүртгэл шаардлагагүй.",
|
||||
"demo_no_signup": "Бүртгүүлэх шаардлагагүй - жишээ өгөгдлөөр чөлөөтэй үзэж сонирхоно уу",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Нэвтрэлтийг дуусгаж байна...",
|
||||
"oauth_error": {
|
||||
"title": "Баталгаажуулалт амжилтгүй",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Start demoen",
|
||||
"demo_tagline": "Prøv en fullverdig e-postklient. Du trenger ingen konto.",
|
||||
"demo_no_signup": "Ingen registrering nødvendig – utforsk fritt med eksempeldata",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Fullfører pålogging...",
|
||||
"oauth_error": {
|
||||
"title": "Autentisering mislyktes",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Demo starten",
|
||||
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
|
||||
"demo_no_signup": "Geen registratie nodig - verken vrij met voorbeeldgegevens",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Aanmelding voltooien...",
|
||||
"oauth_error": {
|
||||
"title": "Authenticatie mislukt",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Uruchom demo",
|
||||
"demo_tagline": "Wypróbuj w pełni funkcjonalnego klienta poczty. Konto nie jest wymagane.",
|
||||
"demo_no_signup": "Bez rejestracji - eksploruj swobodnie z przykładowymi danymi",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Kończenie logowania...",
|
||||
"oauth_error": {
|
||||
"title": "Uwierzytelnianie nie powiodło się",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Iniciar demo",
|
||||
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
|
||||
"demo_no_signup": "Sem registo - explore livremente com dados de exemplo",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Concluindo login...",
|
||||
"oauth_error": {
|
||||
"title": "Falha na autenticação",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Lansați demonstrația",
|
||||
"demo_tagline": "Descoperiți un client de e-mail cu funcționalități complete. Nu este necesar un cont.",
|
||||
"demo_no_signup": "Nu este necesară înregistrarea – explorați liber cu date de exemplu",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Se finalizează autentificarea...",
|
||||
"oauth_error": {
|
||||
"title": "Autentificare eșuată",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Запустить демо",
|
||||
"demo_tagline": "Попробуйте полнофункциональный почтовый клиент. Аккаунт не нужен.",
|
||||
"demo_no_signup": "Регистрация не требуется - исследуйте свободно с примерными данными",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Завершение входа...",
|
||||
"oauth_error": {
|
||||
"title": "Ошибка аутентификации",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Spustiť demo",
|
||||
"demo_tagline": "Vyskúšajte plnohodnotného e-mailového klienta. Účet nie je potrebný.",
|
||||
"demo_no_signup": "Bez registrácie – voľne preskúmajte s ukážkovými dátami",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Dokončovanie prihlásenia...",
|
||||
"oauth_error": {
|
||||
"title": "Overenie zlyhalo",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Demoyu Başlat",
|
||||
"demo_tagline": "Tam özellikli bir e-posta istemcisini deneyimleyin. Hesap gerekmez.",
|
||||
"demo_no_signup": "Kayıt gerekmez - örnek verilerle özgürce keşfedin",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Giriş tamamlanıyor...",
|
||||
"oauth_error": {
|
||||
"title": "Kimlik Doğrulama Başarısız",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "Запустити демонстрацію",
|
||||
"demo_tagline": "Спробуйте повнофункціональний поштовий клієнт. Обліковий запис не потрібен.",
|
||||
"demo_no_signup": "Реєстрація не потрібна - вільно досліджуйте з зразками даних",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "Завершення входу...",
|
||||
"oauth_error": {
|
||||
"title": "Помилка автентифікації",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "啟動示範版",
|
||||
"demo_tagline": "體驗功能完整的電子郵件用戶端,不需要帳號。",
|
||||
"demo_no_signup": "不需要註冊,使用範例資料自由探索",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "正在完成登入…",
|
||||
"oauth_error": {
|
||||
"title": "驗證失敗",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"demo_login_button": "启动演示",
|
||||
"demo_tagline": "体验功能完整的邮件客户端,无需账号。",
|
||||
"demo_no_signup": "无需注册,直接使用示例数据体验",
|
||||
"mobile_handoff": {
|
||||
"title": "Open the Bulwark mobile app?",
|
||||
"description": "This sign-in page was opened by an app on this device. Your sign-in for {username} will be handed to the app that registered the Bulwark link. Continue only if you started this from the Bulwark mobile app.",
|
||||
"confirm": "Hand over to the app",
|
||||
"cancel": "Stay in the webmail"
|
||||
},
|
||||
"oauth_completing": "正在完成登录…",
|
||||
"oauth_error": {
|
||||
"title": "认证失败",
|
||||
|
||||
Reference in New Issue
Block a user